Browse Source

feat: clean up task list in HistoryPreview and History components (#6687)

Co-authored-by: Roo Code <[email protected]>
Co-authored-by: Bruno Bergher <[email protected]>
Co-authored-by: Bruno Bergher <[email protected]>
Co-authored-by: Matt Rubens <[email protected]>
roomote[bot] 4 months ago
parent
commit
24584f54ec
30 changed files with 558 additions and 211 deletions
  1. 1 1
      webview-ui/src/components/common/VersionIndicator.tsx
  2. 1 1
      webview-ui/src/components/history/DeleteButton.tsx
  3. 3 3
      webview-ui/src/components/history/HistoryView.tsx
  4. 15 15
      webview-ui/src/components/history/TaskItem.tsx
  5. 17 35
      webview-ui/src/components/history/TaskItemFooter.tsx
  6. 0 37
      webview-ui/src/components/history/TaskItemHeader.tsx
  7. 2 0
      webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx
  8. 14 22
      webview-ui/src/components/history/__tests__/TaskItem.spec.tsx
  9. 36 24
      webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx
  10. 0 35
      webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx
  11. 16 0
      webview-ui/src/i18n/locales/ca/common.json
  12. 16 0
      webview-ui/src/i18n/locales/de/common.json
  13. 16 0
      webview-ui/src/i18n/locales/en/common.json
  14. 16 0
      webview-ui/src/i18n/locales/es/common.json
  15. 16 0
      webview-ui/src/i18n/locales/fr/common.json
  16. 16 0
      webview-ui/src/i18n/locales/hi/common.json
  17. 16 0
      webview-ui/src/i18n/locales/id/common.json
  18. 16 0
      webview-ui/src/i18n/locales/it/common.json
  19. 16 0
      webview-ui/src/i18n/locales/ja/common.json
  20. 16 0
      webview-ui/src/i18n/locales/ko/common.json
  21. 16 0
      webview-ui/src/i18n/locales/nl/common.json
  22. 16 0
      webview-ui/src/i18n/locales/pl/common.json
  23. 16 0
      webview-ui/src/i18n/locales/pt-BR/common.json
  24. 16 0
      webview-ui/src/i18n/locales/ru/common.json
  25. 16 0
      webview-ui/src/i18n/locales/tr/common.json
  26. 16 0
      webview-ui/src/i18n/locales/vi/common.json
  27. 16 0
      webview-ui/src/i18n/locales/zh-CN/common.json
  28. 16 0
      webview-ui/src/i18n/locales/zh-TW/common.json
  29. 135 32
      webview-ui/src/utils/__tests__/format.spec.ts
  30. 46 6
      webview-ui/src/utils/format.ts

+ 1 - 1
webview-ui/src/components/common/VersionIndicator.tsx

@@ -13,7 +13,7 @@ const VersionIndicator: React.FC<VersionIndicatorProps> = ({ onClick, className
 	return (
 		<button
 			onClick={onClick}
-			className={`text-xs text-vscode-descriptionForeground hover:text-vscode-foreground transition-colors cursor-pointer px-2 py-1 rounded border border-vscode-panel-border hover:border-vscode-focusBorder ${className}`}
+			className={`text-xs text-vscode-descriptionForeground hover:text-vscode-foreground transition-colors cursor-pointer px-2 py-1 rounded border ${className}`}
 			aria-label={t("chat:versionIndicator.ariaLabel", { version: Package.version })}>
 			v{Package.version}
 		</button>

+ 1 - 1
webview-ui/src/components/history/DeleteButton.tsx

@@ -31,7 +31,7 @@ export const DeleteButton = ({ itemId, onDelete }: DeleteButtonProps) => {
 				size="icon"
 				data-testid="delete-task-button"
 				onClick={handleDeleteClick}
-				className="group-hover:opacity-100 opacity-50 transition-opacity">
+				className="opacity-70">
 				<span className="codicon codicon-trash size-4 align-middle text-vscode-descriptionForeground" />
 			</Button>
 		</StandardTooltip>

+ 3 - 3
webview-ui/src/components/history/HistoryView.tsx

@@ -222,7 +222,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 				</div>
 			</TabHeader>
 
-			<TabContent className="p-0">
+			<TabContent className="px-2 py-0">
 				<Virtuoso
 					className="flex-1 overflow-y-scroll"
 					data={tasks}
@@ -243,7 +243,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 							isSelected={selectedTaskIds.includes(item.id)}
 							onToggleSelection={toggleTaskSelection}
 							onDelete={setDeleteTaskId}
-							className="m-2 mr-0"
+							className="m-2"
 						/>
 					)}
 				/>
@@ -251,7 +251,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 
 			{/* Fixed action bar at bottom - only shown in selection mode with selected items */}
 			{isSelectionMode && selectedTaskIds.length > 0 && (
-				<div className="fixed bottom-0 left-0 right-0 bg-vscode-editor-background border-t border-vscode-panel-border p-2 flex justify-between items-center">
+				<div className="fixed bottom-0 left-0 right-2 bg-vscode-editor-background border-t border-vscode-panel-border p-2 flex justify-between items-center">
 					<div className="text-vscode-foreground">
 						{t("history:selectedItems", { selected: selectedTaskIds.length, total: tasks.length })}
 					</div>

+ 15 - 15
webview-ui/src/components/history/TaskItem.tsx

@@ -5,7 +5,6 @@ import { vscode } from "@/utils/vscode"
 import { cn } from "@/lib/utils"
 import { Checkbox } from "@/components/ui/checkbox"
 
-import TaskItemHeader from "./TaskItemHeader"
 import TaskItemFooter from "./TaskItemFooter"
 
 interface DisplayHistoryItem extends HistoryItem {
@@ -48,11 +47,11 @@ const TaskItem = ({
 			key={item.id}
 			data-testid={`task-item-${item.id}`}
 			className={cn(
-				"cursor-pointer group bg-vscode-editor-background rounded relative overflow-hidden hover:border-vscode-toolbar-hoverBackground/60",
+				"cursor-pointer group bg-vscode-editor-background rounded relative overflow-hidden border border-transparent hover:bg-vscode-list-hoverBackground transition-colors",
 				className,
 			)}
 			onClick={handleClick}>
-			<div className="flex gap-2 p-3">
+			<div className={(!isCompact && isSelectionMode ? "pl-3 pb-3" : "pl-4") + " flex gap-3 px-3 pt-3 pb-1"}>
 				{/* Selection checkbox - only in full variant */}
 				{!isCompact && isSelectionMode && (
 					<div
@@ -69,24 +68,25 @@ const TaskItem = ({
 				)}
 
 				<div className="flex-1 min-w-0">
-					{/* Header with metadata */}
-					<TaskItemHeader item={item} isSelectionMode={isSelectionMode} onDelete={onDelete} />
-
-					{/* Task content */}
 					<div
-						className={cn("overflow-hidden whitespace-pre-wrap text-vscode-foreground text-ellipsis", {
-							"text-base line-clamp-3": !isCompact,
-							"line-clamp-2": isCompact,
-						})}
+						className={cn(
+							"overflow-hidden whitespace-pre-wrap text-vscode-foreground text-ellipsis line-clamp-2",
+							{
+								"text-base": !isCompact,
+							},
+							!isCompact && isSelectionMode ? "mb-1" : "",
+						)}
 						data-testid="task-content"
 						{...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}>
 						{item.highlight ? undefined : item.task}
 					</div>
+					<TaskItemFooter
+						item={item}
+						variant={variant}
+						isSelectionMode={isSelectionMode}
+						onDelete={onDelete}
+					/>
 
-					{/* Task Item Footer */}
-					<TaskItemFooter item={item} variant={variant} isSelectionMode={isSelectionMode} />
-
-					{/* Workspace info */}
 					{showWorkspace && item.workspace && (
 						<div className="flex flex-row gap-1 text-vscode-descriptionForeground text-xs mt-1">
 							<span className="codicon codicon-folder scale-80" />

+ 17 - 35
webview-ui/src/components/history/TaskItemFooter.tsx

@@ -1,59 +1,41 @@
 import React from "react"
 import type { HistoryItem } from "@roo-code/types"
-import { Coins, FileIcon } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { formatLargeNumber } from "@/utils/format"
+import { formatTimeAgo } from "@/utils/format"
 import { CopyButton } from "./CopyButton"
 import { ExportButton } from "./ExportButton"
+import { DeleteButton } from "./DeleteButton"
+import { StandardTooltip } from "../ui/standard-tooltip"
 
 export interface TaskItemFooterProps {
 	item: HistoryItem
 	variant: "compact" | "full"
 	isSelectionMode?: boolean
+	onDelete?: (taskId: string) => void
 }
 
-const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelectionMode = false }) => {
+const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelectionMode = false, onDelete }) => {
 	return (
-		<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center mt-1">
-			<div className="flex gap-2">
-				{!!(item.cacheReads || item.cacheWrites) && (
-					<span className="flex items-center" data-testid="cache-compact">
-						<i className="mr-1 codicon codicon-cloud-upload text-sm! text-vscode-descriptionForeground" />
-						<span className="inline-block mr-1">{formatLargeNumber(item.cacheWrites || 0)}</span>
-						<i className="mr-1 codicon codicon-cloud-download text-sm! text-vscode-descriptionForeground" />
-						<span>{formatLargeNumber(item.cacheReads || 0)}</span>
-					</span>
-				)}
-
-				{/* Full Tokens */}
-				{!!(item.tokensIn || item.tokensOut) && (
-					<span className="flex items-center gap-1">
-						<span data-testid="tokens-in-footer-compact">↑ {formatLargeNumber(item.tokensIn || 0)}</span>
-						<span data-testid="tokens-out-footer-compact">↓ {formatLargeNumber(item.tokensOut || 0)}</span>
-					</span>
-				)}
-
-				{/* Full Cost */}
+		<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center">
+			<div className="flex gap-2 items-center text-vscode-descriptionForeground/60">
+				{/* Datetime with time-ago format */}
+				<StandardTooltip content={new Date(item.ts).toLocaleString()}>
+					<span className="first-letter:uppercase">{formatTimeAgo(item.ts)}</span>
+				</StandardTooltip>
+				<span>·</span>
+				{/* Cost */}
 				{!!item.totalCost && (
-					<span className="flex items-center">
-						<Coins className="inline-block size-[1em] mr-1" />
-						<span data-testid="cost-footer-compact">{"$" + item.totalCost.toFixed(2)}</span>
-					</span>
-				)}
-
-				{!!item.size && (
-					<span className="flex items-center">
-						<FileIcon className="inline-block size-[1em] mr-1" />
-						<span data-testid="size-footer-compact">{prettyBytes(item.size)}</span>
+					<span className="flex items-center" data-testid="cost-footer-compact">
+						{"$" + item.totalCost.toFixed(2)}
 					</span>
 				)}
 			</div>
 
 			{/* Action Buttons for non-compact view */}
 			{!isSelectionMode && (
-				<div className="flex flex-row gap-0 items-center opacity-50 hover:opacity-100">
+				<div className="flex flex-row gap-0 items-center text-vscode-descriptionForeground/60 hover:text-vscode-descriptionForeground">
 					<CopyButton itemTask={item.task} />
 					{variant === "full" && <ExportButton itemId={item.id} />}
+					{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
 				</div>
 			)}
 		</div>

+ 0 - 37
webview-ui/src/components/history/TaskItemHeader.tsx

@@ -1,37 +0,0 @@
-import React from "react"
-import type { HistoryItem } from "@roo-code/types"
-import { formatDate } from "@/utils/format"
-import { DeleteButton } from "./DeleteButton"
-import { cn } from "@/lib/utils"
-
-export interface TaskItemHeaderProps {
-	item: HistoryItem
-	isSelectionMode: boolean
-	onDelete?: (taskId: string) => void
-}
-
-const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, isSelectionMode, onDelete }) => {
-	return (
-		<div
-			className={cn("flex justify-between items-center", {
-				// this is to balance out the margin when we don't have a delete button
-				// because the delete button sorta pushes the date up due to its size
-				"mb-1": !onDelete,
-			})}>
-			<div className="flex items-center flex-wrap gap-x-2 text-xs">
-				<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
-					{formatDate(item.ts)}
-				</span>
-			</div>
-
-			{/* Action Buttons */}
-			{!isSelectionMode && (
-				<div className="flex flex-row gap-0 items-center opacity-20 group-hover:opacity-50 hover:opacity-100">
-					{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
-				</div>
-			)}
-		</div>
-	)
-}
-
-export default TaskItemHeader

+ 2 - 0
webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx

@@ -148,6 +148,8 @@ describe("HistoryPreview", () => {
 		expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument()
 		expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument()
 		expect(screen.queryByTestId("task-item-task-4")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("task-item-task-5")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("task-item-task-6")).not.toBeInTheDocument()
 	})
 
 	it("renders only 1 task when there is only 1 task", () => {

+ 14 - 22
webview-ui/src/components/history/__tests__/TaskItem.spec.tsx

@@ -9,6 +9,12 @@ vi.mock("@src/i18n/TranslationContext", () => ({
 	}),
 }))
 
+vi.mock("@/utils/format", () => ({
+	formatTimeAgo: vi.fn(() => "2 hours ago"),
+	formatDate: vi.fn(() => "January 15 at 2:30 PM"),
+	formatLargeNumber: vi.fn((num: number) => num.toString()),
+}))
+
 const mockTask = {
 	id: "1",
 	number: 1,
@@ -74,16 +80,10 @@ describe("TaskItem", () => {
 		expect(screen.getByTestId("export")).toBeInTheDocument()
 	})
 
-	it("displays cache information when present", () => {
-		const mockTaskWithCache = {
-			...mockTask,
-			cacheReads: 10,
-			cacheWrites: 5,
-		}
-
+	it("displays time ago information", () => {
 		render(
 			<TaskItem
-				item={mockTaskWithCache}
+				item={mockTask}
 				variant="full"
 				isSelected={false}
 				onToggleSelection={vi.fn()}
@@ -91,22 +91,14 @@ describe("TaskItem", () => {
 			/>,
 		)
 
-		// Should display cache information in the footer
-		expect(screen.getByTestId("cache-compact")).toBeInTheDocument()
-		expect(screen.getByText("5")).toBeInTheDocument() // cache writes
-		expect(screen.getByText("10")).toBeInTheDocument() // cache reads
+		// Should display time ago format
+		expect(screen.getByText(/ago/)).toBeInTheDocument()
 	})
 
-	it("does not display cache information when not present", () => {
-		const mockTaskWithoutCache = {
-			...mockTask,
-			cacheReads: 0,
-			cacheWrites: 0,
-		}
-
+	it("applies hover effect class", () => {
 		render(
 			<TaskItem
-				item={mockTaskWithoutCache}
+				item={mockTask}
 				variant="full"
 				isSelected={false}
 				onToggleSelection={vi.fn()}
@@ -114,7 +106,7 @@ describe("TaskItem", () => {
 			/>,
 		)
 
-		// Cache section should not be present
-		expect(screen.queryByTestId("cache-compact")).not.toBeInTheDocument()
+		const taskItem = screen.getByTestId("task-item-1")
+		expect(taskItem).toHaveClass("hover:bg-vscode-list-hoverBackground")
 	})
 })

+ 36 - 24
webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx

@@ -8,6 +8,12 @@ vi.mock("@src/i18n/TranslationContext", () => ({
 	}),
 }))
 
+vi.mock("@/utils/format", () => ({
+	formatTimeAgo: vi.fn(() => "2 hours ago"),
+	formatDate: vi.fn(() => "January 15 at 2:30 PM"),
+	formatLargeNumber: vi.fn((num: number) => num.toString()),
+}))
+
 const mockItem = {
 	id: "1",
 	number: 1,
@@ -20,12 +26,11 @@ const mockItem = {
 }
 
 describe("TaskItemFooter", () => {
-	it("renders token information", () => {
+	it("renders time ago information", () => {
 		render(<TaskItemFooter item={mockItem} variant="full" />)
 
-		// Check for token counts using testids since the text is split across elements
-		expect(screen.getByTestId("tokens-in-footer-compact")).toBeInTheDocument()
-		expect(screen.getByTestId("tokens-out-footer-compact")).toBeInTheDocument()
+		// Should show time ago format
+		expect(screen.getByText(/ago/)).toBeInTheDocument()
 	})
 
 	it("renders cost information", () => {
@@ -43,31 +48,38 @@ describe("TaskItemFooter", () => {
 		expect(screen.getByTestId("export")).toBeInTheDocument()
 	})
 
-	it("renders cache information when present", () => {
-		const mockItemWithCache = {
-			...mockItem,
-			cacheReads: 5,
-			cacheWrites: 3,
-		}
+	it("hides export button in compact variant", () => {
+		render(<TaskItemFooter item={mockItem} variant="compact" />)
+
+		// Should show copy button but not export button
+		expect(screen.getByTestId("copy-prompt-button")).toBeInTheDocument()
+		expect(screen.queryByTestId("export")).not.toBeInTheDocument()
+	})
+
+	it("hides action buttons in selection mode", () => {
+		render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={true} />)
+
+		// Should not show any action buttons
+		expect(screen.queryByTestId("copy-prompt-button")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("export")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument()
+	})
 
-		render(<TaskItemFooter item={mockItemWithCache} variant="full" />)
+	it("shows delete button when not in selection mode and onDelete is provided", () => {
+		render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={false} onDelete={vi.fn()} />)
 
-		// Check for cache display using testid
-		expect(screen.getByTestId("cache-compact")).toBeInTheDocument()
-		expect(screen.getByText("3")).toBeInTheDocument() // cache writes
-		expect(screen.getByText("5")).toBeInTheDocument() // cache reads
+		expect(screen.getByTestId("delete-task-button")).toBeInTheDocument()
 	})
 
-	it("does not render cache information when not present", () => {
-		const mockItemWithoutCache = {
-			...mockItem,
-			cacheReads: 0,
-			cacheWrites: 0,
-		}
+	it("does not show delete button in selection mode", () => {
+		render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={true} onDelete={vi.fn()} />)
+
+		expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument()
+	})
 
-		render(<TaskItemFooter item={mockItemWithoutCache} variant="full" />)
+	it("does not show delete button when onDelete is not provided", () => {
+		render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={false} />)
 
-		// Cache section should not be present
-		expect(screen.queryByTestId("cache-compact")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument()
 	})
 })

+ 0 - 35
webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx

@@ -1,35 +0,0 @@
-import { render, screen } from "@/utils/test-utils"
-
-import TaskItemHeader from "../TaskItemHeader"
-
-vi.mock("@src/i18n/TranslationContext", () => ({
-	useAppTranslation: () => ({
-		t: (key: string) => key,
-	}),
-}))
-
-const mockItem = {
-	id: "1",
-	number: 1,
-	task: "Test task",
-	ts: Date.now(),
-	tokensIn: 100,
-	tokensOut: 50,
-	totalCost: 0.002,
-	workspace: "/test/workspace",
-}
-
-describe("TaskItemHeader", () => {
-	it("renders date information", () => {
-		render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={vi.fn()} />)
-
-		// TaskItemHeader shows the formatted date, not the task text
-		expect(screen.getByText(/\w+ \d{1,2}, \d{1,2}:\d{2} \w{2}/)).toBeInTheDocument() // Date format like "JUNE 14, 10:15 AM"
-	})
-
-	it("shows delete button when not in selection mode", () => {
-		render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={vi.fn()} />)
-
-		expect(screen.getByRole("button")).toBeInTheDocument()
-	})
-})

+ 16 - 0
webview-ui/src/i18n/locales/ca/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Editar missatge",
 		"editWarning": "Editar aquest missatge eliminarà tots els missatges posteriors de la conversa. Vols continuar?",
 		"proceed": "Continuar"
+	},
+	"time_ago": {
+		"just_now": "ara mateix",
+		"seconds_ago": "fa {{count}} segons",
+		"minute_ago": "fa un minut",
+		"minutes_ago": "fa {{count}} minuts",
+		"hour_ago": "fa una hora",
+		"hours_ago": "fa {{count}} hores",
+		"day_ago": "fa un dia",
+		"days_ago": "fa {{count}} dies",
+		"week_ago": "fa una setmana",
+		"weeks_ago": "fa {{count}} setmanes",
+		"month_ago": "fa un mes",
+		"months_ago": "fa {{count}} mesos",
+		"year_ago": "fa un any",
+		"years_ago": "fa {{count}} anys"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/de/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Nachricht bearbeiten",
 		"editWarning": "Das Bearbeiten dieser Nachricht wird alle nachfolgenden Nachrichten in der Unterhaltung löschen. Möchtest du fortfahren?",
 		"proceed": "Fortfahren"
+	},
+	"time_ago": {
+		"just_now": "gerade eben",
+		"seconds_ago": "vor {{count}} Sekunden",
+		"minute_ago": "vor einer Minute",
+		"minutes_ago": "vor {{count}} Minuten",
+		"hour_ago": "vor einer Stunde",
+		"hours_ago": "vor {{count}} Stunden",
+		"day_ago": "vor einem Tag",
+		"days_ago": "vor {{count}} Tagen",
+		"week_ago": "vor einer Woche",
+		"weeks_ago": "vor {{count}} Wochen",
+		"month_ago": "vor einem Monat",
+		"months_ago": "vor {{count}} Monaten",
+		"year_ago": "vor einem Jahr",
+		"years_ago": "vor {{count}} Jahren"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/en/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Edit Message",
 		"editWarning": "Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?",
 		"proceed": "Proceed"
+	},
+	"time_ago": {
+		"just_now": "just now",
+		"seconds_ago": "{{count}} seconds ago",
+		"minute_ago": "a minute ago",
+		"minutes_ago": "{{count}} minutes ago",
+		"hour_ago": "an hour ago",
+		"hours_ago": "{{count}} hours ago",
+		"day_ago": "a day ago",
+		"days_ago": "{{count}} days ago",
+		"week_ago": "a week ago",
+		"weeks_ago": "{{count}} weeks ago",
+		"month_ago": "a month ago",
+		"months_ago": "{{count}} months ago",
+		"year_ago": "a year ago",
+		"years_ago": "{{count}} years ago"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/es/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Editar mensaje",
 		"editWarning": "Editar este mensaje eliminará todos los mensajes posteriores en la conversación. ¿Deseas continuar?",
 		"proceed": "Continuar"
+	},
+	"time_ago": {
+		"just_now": "ahora mismo",
+		"seconds_ago": "hace {{count}} segundos",
+		"minute_ago": "hace un minuto",
+		"minutes_ago": "hace {{count}} minutos",
+		"hour_ago": "hace una hora",
+		"hours_ago": "hace {{count}} horas",
+		"day_ago": "hace un día",
+		"days_ago": "hace {{count}} días",
+		"week_ago": "hace una semana",
+		"weeks_ago": "hace {{count}} semanas",
+		"month_ago": "hace un mes",
+		"months_ago": "hace {{count}} meses",
+		"year_ago": "hace un año",
+		"years_ago": "hace {{count}} años"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/fr/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Modifier le message",
 		"editWarning": "Modifier ce message supprimera tous les messages suivants dans la conversation. Voulez-vous continuer ?",
 		"proceed": "Continuer"
+	},
+	"time_ago": {
+		"just_now": "à l'instant",
+		"seconds_ago": "il y a {{count}} secondes",
+		"minute_ago": "il y a une minute",
+		"minutes_ago": "il y a {{count}} minutes",
+		"hour_ago": "il y a une heure",
+		"hours_ago": "il y a {{count}} heures",
+		"day_ago": "il y a un jour",
+		"days_ago": "il y a {{count}} jours",
+		"week_ago": "il y a une semaine",
+		"weeks_ago": "il y a {{count}} semaines",
+		"month_ago": "il y a un mois",
+		"months_ago": "il y a {{count}} mois",
+		"year_ago": "il y a un an",
+		"years_ago": "il y a {{count}} ans"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/hi/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "संदेश संपादित करें",
 		"editWarning": "इस संदेश को संपादित करने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप जारी रखना चाहते हैं?",
 		"proceed": "जारी रखें"
+	},
+	"time_ago": {
+		"just_now": "अभी",
+		"seconds_ago": "{{count}} सेकंड पहले",
+		"minute_ago": "एक मिनट पहले",
+		"minutes_ago": "{{count}} मिनट पहले",
+		"hour_ago": "एक घंटे पहले",
+		"hours_ago": "{{count}} घंटे पहले",
+		"day_ago": "एक दिन पहले",
+		"days_ago": "{{count}} दिन पहले",
+		"week_ago": "एक सप्ताह पहले",
+		"weeks_ago": "{{count}} सप्ताह पहले",
+		"month_ago": "एक महीने पहले",
+		"months_ago": "{{count}} महीने पहले",
+		"year_ago": "एक साल पहले",
+		"years_ago": "{{count}} साल पहले"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/id/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Edit Pesan",
 		"editWarning": "Mengedit pesan ini akan menghapus semua pesan selanjutnya dalam percakapan. Apakah kamu ingin melanjutkan?",
 		"proceed": "Lanjutkan"
+	},
+	"time_ago": {
+		"just_now": "baru saja",
+		"seconds_ago": "{{count}} detik yang lalu",
+		"minute_ago": "satu menit yang lalu",
+		"minutes_ago": "{{count}} menit yang lalu",
+		"hour_ago": "satu jam yang lalu",
+		"hours_ago": "{{count}} jam yang lalu",
+		"day_ago": "satu hari yang lalu",
+		"days_ago": "{{count}} hari yang lalu",
+		"week_ago": "satu minggu yang lalu",
+		"weeks_ago": "{{count}} minggu yang lalu",
+		"month_ago": "satu bulan yang lalu",
+		"months_ago": "{{count}} bulan yang lalu",
+		"year_ago": "satu tahun yang lalu",
+		"years_ago": "{{count}} tahun yang lalu"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/it/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Modifica Messaggio",
 		"editWarning": "Modificando questo messaggio verranno eliminati tutti i messaggi successivi nella conversazione. Vuoi procedere?",
 		"proceed": "Procedi"
+	},
+	"time_ago": {
+		"just_now": "proprio ora",
+		"seconds_ago": "{{count}} secondi fa",
+		"minute_ago": "un minuto fa",
+		"minutes_ago": "{{count}} minuti fa",
+		"hour_ago": "un'ora fa",
+		"hours_ago": "{{count}} ore fa",
+		"day_ago": "un giorno fa",
+		"days_ago": "{{count}} giorni fa",
+		"week_ago": "una settimana fa",
+		"weeks_ago": "{{count}} settimane fa",
+		"month_ago": "un mese fa",
+		"months_ago": "{{count}} mesi fa",
+		"year_ago": "un anno fa",
+		"years_ago": "{{count}} anni fa"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/ja/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "メッセージを編集",
 		"editWarning": "このメッセージを編集すると、会話内の後続のメッセージもすべて削除されます。続行しますか?",
 		"proceed": "続行"
+	},
+	"time_ago": {
+		"just_now": "たった今",
+		"seconds_ago": "{{count}}秒前",
+		"minute_ago": "1分前",
+		"minutes_ago": "{{count}}分前",
+		"hour_ago": "1時間前",
+		"hours_ago": "{{count}}時間前",
+		"day_ago": "1日前",
+		"days_ago": "{{count}}日前",
+		"week_ago": "1週間前",
+		"weeks_ago": "{{count}}週間前",
+		"month_ago": "1ヶ月前",
+		"months_ago": "{{count}}ヶ月前",
+		"year_ago": "1年前",
+		"years_ago": "{{count}}年前"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/ko/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "메시지 편집",
 		"editWarning": "이 메시지를 편집하면 대화의 모든 후속 메시지가 삭제됩니다. 계속하시겠습니까?",
 		"proceed": "계속"
+	},
+	"time_ago": {
+		"just_now": "방금",
+		"seconds_ago": "{{count}}초 전",
+		"minute_ago": "1분 전",
+		"minutes_ago": "{{count}}분 전",
+		"hour_ago": "1시간 전",
+		"hours_ago": "{{count}}시간 전",
+		"day_ago": "1일 전",
+		"days_ago": "{{count}}일 전",
+		"week_ago": "1주일 전",
+		"weeks_ago": "{{count}}주일 전",
+		"month_ago": "1개월 전",
+		"months_ago": "{{count}}개월 전",
+		"year_ago": "1년 전",
+		"years_ago": "{{count}}년 전"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/nl/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Bericht Bewerken",
 		"editWarning": "Het bewerken van dit bericht zal alle volgende berichten in het gesprek verwijderen. Wil je doorgaan?",
 		"proceed": "Doorgaan"
+	},
+	"time_ago": {
+		"just_now": "zojuist",
+		"seconds_ago": "{{count}} seconden geleden",
+		"minute_ago": "een minuut geleden",
+		"minutes_ago": "{{count}} minuten geleden",
+		"hour_ago": "een uur geleden",
+		"hours_ago": "{{count}} uur geleden",
+		"day_ago": "een dag geleden",
+		"days_ago": "{{count}} dagen geleden",
+		"week_ago": "een week geleden",
+		"weeks_ago": "{{count}} weken geleden",
+		"month_ago": "een maand geleden",
+		"months_ago": "{{count}} maanden geleden",
+		"year_ago": "een jaar geleden",
+		"years_ago": "{{count}} jaar geleden"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/pl/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Edytuj Wiadomość",
 		"editWarning": "Edycja tej wiadomości spowoduje usunięcie wszystkich kolejnych wiadomości w rozmowie. Czy chcesz kontynuować?",
 		"proceed": "Kontynuuj"
+	},
+	"time_ago": {
+		"just_now": "przed chwilą",
+		"seconds_ago": "{{count}} sekund temu",
+		"minute_ago": "minutę temu",
+		"minutes_ago": "{{count}} minut temu",
+		"hour_ago": "godzinę temu",
+		"hours_ago": "{{count}} godzin temu",
+		"day_ago": "dzień temu",
+		"days_ago": "{{count}} dni temu",
+		"week_ago": "tydzień temu",
+		"weeks_ago": "{{count}} tygodni temu",
+		"month_ago": "miesiąc temu",
+		"months_ago": "{{count}} miesięcy temu",
+		"year_ago": "rok temu",
+		"years_ago": "{{count}} lat temu"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/pt-BR/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Editar Mensagem",
 		"editWarning": "Editar esta mensagem irá excluir todas as mensagens subsequentes na conversa. Deseja prosseguir?",
 		"proceed": "Prosseguir"
+	},
+	"time_ago": {
+		"just_now": "agora mesmo",
+		"seconds_ago": "há {{count}} segundos",
+		"minute_ago": "há um minuto",
+		"minutes_ago": "há {{count}} minutos",
+		"hour_ago": "há uma hora",
+		"hours_ago": "há {{count}} horas",
+		"day_ago": "há um dia",
+		"days_ago": "há {{count}} dias",
+		"week_ago": "há uma semana",
+		"weeks_ago": "há {{count}} semanas",
+		"month_ago": "há um mês",
+		"months_ago": "há {{count}} meses",
+		"year_ago": "há um ano",
+		"years_ago": "há {{count}} anos"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/ru/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Редактировать Сообщение",
 		"editWarning": "Редактирование этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите продолжить?",
 		"proceed": "Продолжить"
+	},
+	"time_ago": {
+		"just_now": "только что",
+		"seconds_ago": "{{count}} секунд назад",
+		"minute_ago": "минуту назад",
+		"minutes_ago": "{{count}} минут назад",
+		"hour_ago": "час назад",
+		"hours_ago": "{{count}} часов назад",
+		"day_ago": "день назад",
+		"days_ago": "{{count}} дней назад",
+		"week_ago": "неделю назад",
+		"weeks_ago": "{{count}} недель назад",
+		"month_ago": "месяц назад",
+		"months_ago": "{{count}} месяцев назад",
+		"year_ago": "год назад",
+		"years_ago": "{{count}} лет назад"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/tr/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Mesajı Düzenle",
 		"editWarning": "Bu mesajı düzenlemek, konuşmadaki sonraki tüm mesajları da silecektir. Devam etmek istiyor musun?",
 		"proceed": "Devam Et"
+	},
+	"time_ago": {
+		"just_now": "şimdi",
+		"seconds_ago": "{{count}} saniye önce",
+		"minute_ago": "bir dakika önce",
+		"minutes_ago": "{{count}} dakika önce",
+		"hour_ago": "bir saat önce",
+		"hours_ago": "{{count}} saat önce",
+		"day_ago": "bir gün önce",
+		"days_ago": "{{count}} gün önce",
+		"week_ago": "bir hafta önce",
+		"weeks_ago": "{{count}} hafta önce",
+		"month_ago": "bir ay önce",
+		"months_ago": "{{count}} ay önce",
+		"year_ago": "bir yıl önce",
+		"years_ago": "{{count}} yıl önce"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/vi/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "Chỉnh Sửa Tin Nhắn",
 		"editWarning": "Chỉnh sửa tin nhắn này sẽ xóa tất cả các tin nhắn tiếp theo trong cuộc trò chuyện. Bạn có muốn tiếp tục không?",
 		"proceed": "Tiếp Tục"
+	},
+	"time_ago": {
+		"just_now": "vừa xong",
+		"seconds_ago": "{{count}} giây trước",
+		"minute_ago": "một phút trước",
+		"minutes_ago": "{{count}} phút trước",
+		"hour_ago": "một giờ trước",
+		"hours_ago": "{{count}} giờ trước",
+		"day_ago": "một ngày trước",
+		"days_ago": "{{count}} ngày trước",
+		"week_ago": "một tuần trước",
+		"weeks_ago": "{{count}} tuần trước",
+		"month_ago": "một tháng trước",
+		"months_ago": "{{count}} tháng trước",
+		"year_ago": "một năm trước",
+		"years_ago": "{{count}} năm trước"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/zh-CN/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "编辑消息",
 		"editWarning": "编辑此消息将删除对话中的所有后续消息。是否继续?",
 		"proceed": "继续"
+	},
+	"time_ago": {
+		"just_now": "刚刚",
+		"seconds_ago": "{{count}}秒前",
+		"minute_ago": "1分钟前",
+		"minutes_ago": "{{count}}分钟前",
+		"hour_ago": "1小时前",
+		"hours_ago": "{{count}}小时前",
+		"day_ago": "1天前",
+		"days_ago": "{{count}}天前",
+		"week_ago": "1周前",
+		"weeks_ago": "{{count}}周前",
+		"month_ago": "1个月前",
+		"months_ago": "{{count}}个月前",
+		"year_ago": "1年前",
+		"years_ago": "{{count}}年前"
 	}
 }

+ 16 - 0
webview-ui/src/i18n/locales/zh-TW/common.json

@@ -67,5 +67,21 @@
 		"editMessage": "編輯訊息",
 		"editWarning": "編輯此訊息將刪除對話中的所有後續訊息。是否繼續?",
 		"proceed": "繼續"
+	},
+	"time_ago": {
+		"just_now": "剛剛",
+		"seconds_ago": "{{count}}秒前",
+		"minute_ago": "1分鐘前",
+		"minutes_ago": "{{count}}分鐘前",
+		"hour_ago": "1小時前",
+		"hours_ago": "{{count}}小時前",
+		"day_ago": "1天前",
+		"days_ago": "{{count}}天前",
+		"week_ago": "1週前",
+		"weeks_ago": "{{count}}週前",
+		"month_ago": "1個月前",
+		"months_ago": "{{count}}個月前",
+		"year_ago": "1年前",
+		"years_ago": "{{count}}年前"
 	}
 }

+ 135 - 32
webview-ui/src/utils/__tests__/format.spec.ts

@@ -1,51 +1,154 @@
-// npx vitest src/utils/__tests__/format.spec.ts
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { formatLargeNumber, formatDate, formatTimeAgo } from "../format"
 
-import { formatDate } from "../format"
+// Mock i18next
+vi.mock("i18next", () => ({
+	default: {
+		t: vi.fn((key: string, options?: any) => {
+			// Mock translations for testing
+			const translations: Record<string, string> = {
+				"common:number_format.billion_suffix": "b",
+				"common:number_format.million_suffix": "m",
+				"common:number_format.thousand_suffix": "k",
+				"common:time_ago.just_now": "just now",
+				"common:time_ago.seconds_ago": "{{count}} seconds ago",
+				"common:time_ago.minute_ago": "a minute ago",
+				"common:time_ago.minutes_ago": "{{count}} minutes ago",
+				"common:time_ago.hour_ago": "an hour ago",
+				"common:time_ago.hours_ago": "{{count}} hours ago",
+				"common:time_ago.day_ago": "a day ago",
+				"common:time_ago.days_ago": "{{count}} days ago",
+				"common:time_ago.week_ago": "a week ago",
+				"common:time_ago.weeks_ago": "{{count}} weeks ago",
+				"common:time_ago.month_ago": "a month ago",
+				"common:time_ago.months_ago": "{{count}} months ago",
+				"common:time_ago.year_ago": "a year ago",
+				"common:time_ago.years_ago": "{{count}} years ago",
+			}
+
+			let result = translations[key] || key
+			if (options?.count !== undefined) {
+				result = result.replace("{{count}}", options.count.toString())
+			}
+			return result
+		}),
+		language: "en",
+	},
+}))
+
+describe("formatLargeNumber", () => {
+	it("should format billions", () => {
+		expect(formatLargeNumber(1500000000)).toBe("1.5b")
+		expect(formatLargeNumber(2000000000)).toBe("2.0b")
+	})
+
+	it("should format millions", () => {
+		expect(formatLargeNumber(1500000)).toBe("1.5m")
+		expect(formatLargeNumber(2000000)).toBe("2.0m")
+	})
+
+	it("should format thousands", () => {
+		expect(formatLargeNumber(1500)).toBe("1.5k")
+		expect(formatLargeNumber(2000)).toBe("2.0k")
+	})
+
+	it("should return string for small numbers", () => {
+		expect(formatLargeNumber(999)).toBe("999")
+		expect(formatLargeNumber(100)).toBe("100")
+	})
+})
 
 describe("formatDate", () => {
-	it("formats a timestamp correctly", () => {
-		// January 15, 2023, 10:30 AM
-		const timestamp = new Date(2023, 0, 15, 10, 30).getTime()
+	it("should format date in English", () => {
+		const timestamp = new Date("2024-01-15T14:30:00").getTime()
 		const result = formatDate(timestamp)
+		// The exact format depends on the locale, but it should contain the date components
+		expect(result).toMatch(/january|jan/i)
+		expect(result).toMatch(/15/)
+	})
+})
+
+describe("formatTimeAgo", () => {
+	let originalDateNow: () => number
+
+	beforeEach(() => {
+		// Mock Date.now to have a consistent "now" time
+		originalDateNow = Date.now
+		Date.now = vi.fn(() => new Date("2024-01-15T12:00:00").getTime())
+	})
+
+	afterEach(() => {
+		// Restore original Date.now
+		Date.now = originalDateNow
+	})
+
+	it('should return "just now" for very recent times', () => {
+		const timestamp = new Date("2024-01-15T11:59:35").getTime() // 25 seconds ago
+		expect(formatTimeAgo(timestamp)).toBe("just now")
+	})
 
-		expect(result).toBe("JANUARY 15, 10:30 AM")
+	it("should format seconds ago", () => {
+		const timestamp = new Date("2024-01-15T11:59:15").getTime() // 45 seconds ago
+		expect(formatTimeAgo(timestamp)).toBe("45 seconds ago")
 	})
 
-	it("handles different months correctly", () => {
-		// February 28, 2023, 3:45 PM
-		const timestamp1 = new Date(2023, 1, 28, 15, 45).getTime()
-		expect(formatDate(timestamp1)).toBe("FEBRUARY 28, 3:45 PM")
+	it("should format a minute ago", () => {
+		const timestamp = new Date("2024-01-15T11:59:00").getTime() // 1 minute ago
+		expect(formatTimeAgo(timestamp)).toBe("a minute ago")
+	})
 
-		// December 31, 2023, 11:59 PM
-		const timestamp2 = new Date(2023, 11, 31, 23, 59).getTime()
-		expect(formatDate(timestamp2)).toBe("DECEMBER 31, 11:59 PM")
+	it("should format minutes ago", () => {
+		const timestamp = new Date("2024-01-15T11:45:00").getTime() // 15 minutes ago
+		expect(formatTimeAgo(timestamp)).toBe("15 minutes ago")
 	})
 
-	it("handles AM/PM correctly", () => {
-		// Morning time - 7:05 AM
-		const morningTimestamp = new Date(2023, 5, 15, 7, 5).getTime()
-		expect(formatDate(morningTimestamp)).toBe("JUNE 15, 7:05 AM")
+	it("should format an hour ago", () => {
+		const timestamp = new Date("2024-01-15T11:00:00").getTime() // 1 hour ago
+		expect(formatTimeAgo(timestamp)).toBe("an hour ago")
+	})
 
-		// Noon - 12:00 PM
-		const noonTimestamp = new Date(2023, 5, 15, 12, 0).getTime()
-		expect(formatDate(noonTimestamp)).toBe("JUNE 15, 12:00 PM")
+	it("should format hours ago", () => {
+		const timestamp = new Date("2024-01-15T09:00:00").getTime() // 3 hours ago
+		expect(formatTimeAgo(timestamp)).toBe("3 hours ago")
+	})
 
-		// Evening time - 8:15 PM
-		const eveningTimestamp = new Date(2023, 5, 15, 20, 15).getTime()
-		expect(formatDate(eveningTimestamp)).toBe("JUNE 15, 8:15 PM")
+	it("should format a day ago", () => {
+		const timestamp = new Date("2024-01-14T12:00:00").getTime() // 1 day ago
+		expect(formatTimeAgo(timestamp)).toBe("a day ago")
 	})
 
-	it("handles single-digit minutes with leading zeros", () => {
-		// 9:05 AM
-		const timestamp = new Date(2023, 3, 10, 9, 5).getTime()
-		expect(formatDate(timestamp)).toBe("APRIL 10, 9:05 AM")
+	it("should format days ago", () => {
+		const timestamp = new Date("2024-01-12T12:00:00").getTime() // 3 days ago
+		expect(formatTimeAgo(timestamp)).toBe("3 days ago")
 	})
 
-	it("converts the result to uppercase", () => {
-		const timestamp = new Date(2023, 8, 21, 16, 45).getTime()
-		const result = formatDate(timestamp)
+	it("should format a week ago", () => {
+		const timestamp = new Date("2024-01-08T12:00:00").getTime() // 7 days ago
+		expect(formatTimeAgo(timestamp)).toBe("a week ago")
+	})
+
+	it("should format weeks ago", () => {
+		const timestamp = new Date("2024-01-01T12:00:00").getTime() // 14 days ago
+		expect(formatTimeAgo(timestamp)).toBe("2 weeks ago")
+	})
+
+	it("should format a month ago", () => {
+		const timestamp = new Date("2023-12-15T12:00:00").getTime() // ~1 month ago
+		expect(formatTimeAgo(timestamp)).toBe("a month ago")
+	})
+
+	it("should format months ago", () => {
+		const timestamp = new Date("2023-10-15T12:00:00").getTime() // ~3 months ago
+		expect(formatTimeAgo(timestamp)).toBe("3 months ago")
+	})
+
+	it("should format a year ago", () => {
+		const timestamp = new Date("2023-01-15T12:00:00").getTime() // 1 year ago
+		expect(formatTimeAgo(timestamp)).toBe("a year ago")
+	})
 
-		expect(result).toBe(result.toUpperCase())
-		expect(result).toBe("SEPTEMBER 21, 4:45 PM")
+	it("should format years ago", () => {
+		const timestamp = new Date("2021-01-15T12:00:00").getTime() // 3 years ago
+		expect(formatTimeAgo(timestamp)).toBe("3 years ago")
 	})
 })

+ 46 - 6
webview-ui/src/utils/format.ts

@@ -17,19 +17,59 @@ export const formatDate = (timestamp: number) => {
 	const date = new Date(timestamp)
 	const locale = i18next.language || "en"
 
-	// Get date format style from translations or use default transformations
-	const dateStr = date.toLocaleString(locale, {
+	return date.toLocaleString(locale, {
 		month: "long",
 		day: "numeric",
 		hour: "numeric",
 		minute: "2-digit",
 		hour12: true,
 	})
+}
+
+export const formatTimeAgo = (timestamp: number) => {
+	const now = Date.now()
+	const diff = now - timestamp
+	const seconds = Math.floor(diff / 1000)
+	const minutes = Math.floor(seconds / 60)
+	const hours = Math.floor(minutes / 60)
+	const days = Math.floor(hours / 24)
+	const weeks = Math.floor(days / 7)
+	const months = Math.floor(days / 30)
+	const years = Math.floor(days / 365)
 
-	// Apply transformations based on locale or use default
-	if (locale === "en") {
-		return dateStr.replace(", ", " ").replace(" at", ",").toUpperCase()
+	if (years > 0) {
+		return years === 1
+			? i18next.t("common:time_ago.year_ago")
+			: i18next.t("common:time_ago.years_ago", { count: years })
+	}
+	if (months > 0) {
+		return months === 1
+			? i18next.t("common:time_ago.month_ago")
+			: i18next.t("common:time_ago.months_ago", { count: months })
+	}
+	if (weeks > 0) {
+		return weeks === 1
+			? i18next.t("common:time_ago.week_ago")
+			: i18next.t("common:time_ago.weeks_ago", { count: weeks })
+	}
+	if (days > 0) {
+		return days === 1
+			? i18next.t("common:time_ago.day_ago")
+			: i18next.t("common:time_ago.days_ago", { count: days })
+	}
+	if (hours > 0) {
+		return hours === 1
+			? i18next.t("common:time_ago.hour_ago")
+			: i18next.t("common:time_ago.hours_ago", { count: hours })
+	}
+	if (minutes > 0) {
+		return minutes === 1
+			? i18next.t("common:time_ago.minute_ago")
+			: i18next.t("common:time_ago.minutes_ago", { count: minutes })
+	}
+	if (seconds > 30) {
+		return i18next.t("common:time_ago.seconds_ago", { count: seconds })
 	}
 
-	return dateStr.toUpperCase()
+	return i18next.t("common:time_ago.just_now")
 }