| author | |
| committer | |
| log | 727c0cfcbf1733edd9f23974f67c755ccbf6eee5 |
| tree | 1f8018c9f73b2a71c8b7d554d5d038cbeb3c9b25 |
| parent | dcd2e5c34fbbf6cba7cdb23caef86568771a78d4 |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 209 insertions(+), 55 deletions(-)
apps/web/src/appSettings.test.ts+7| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | DEFAULT_TIMESTAMP_FORMAT, | |
| 4 | 5 | getAppModelOptions, |
| 5 | 6 | normalizeCustomModelSlugs, |
| 6 | 7 | resolveAppModelSelection, |
| ... | ... | @@ -57,3 +58,9 @@ describe("resolveAppModelSelection", () => { |
| 57 | 58 | expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); |
| 58 | 59 | }); |
| 59 | 60 | }); |
| 61 | ||
| 62 | describe("timestamp format defaults", () => { | |
| 63 | it("defaults timestamp format to locale", () => { | |
| 64 | expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); | |
| 65 | }); | |
| 66 | }); |
apps/web/src/appSettings.ts+6| ... | ... | @@ -7,6 +7,9 @@ import { useLocalStorage } from "./hooks/useLocalStorage"; |
| 7 | 7 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 8 | 8 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 9 | 9 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 10 | export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; | |
| 11 | export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; | |
| 12 | export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; | |
| 10 | 13 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { |
| 11 | 14 | codex: new Set(getModelOptions("codex").map((option) => option.slug)), |
| 12 | 15 | }; |
| ... | ... | @@ -22,6 +25,9 @@ const AppSettingsSchema = Schema.Struct({ |
| 22 | 25 | enableAssistantStreaming: Schema.Boolean.pipe( |
| 23 | 26 | Schema.withConstructorDefault(() => Option.some(false)), |
| 24 | 27 | ), |
| 28 | timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( | |
| 29 | Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), | |
| 30 | ), | |
| 25 | 31 | customCodexModels: Schema.Array(Schema.String).pipe( |
| 26 | 32 | Schema.withConstructorDefault(() => Option.some([])), |
| 27 | 33 | ), |
apps/web/src/components/ChatView.tsx+3| ... | ... | @@ -184,6 +184,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 184 | 184 | const setStoreThreadError = useStore((store) => store.setError); |
| 185 | 185 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); |
| 186 | 186 | const { settings } = useAppSettings(); |
| 187 | const timestampFormat = settings.timestampFormat; | |
| 187 | 188 | const navigate = useNavigate(); |
| 188 | 189 | const rawSearch = useSearch({ |
| 189 | 190 | strict: false, |
| ... | ... | @@ -3244,6 +3245,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3244 | 3245 | onImageExpand={onExpandTimelineImage} |
| 3245 | 3246 | markdownCwd={gitCwd ?? undefined} |
| 3246 | 3247 | resolvedTheme={resolvedTheme} |
| 3248 | timestampFormat={timestampFormat} | |
| 3247 | 3249 | workspaceRoot={activeProject?.cwd ?? undefined} |
| 3248 | 3250 | /> |
| 3249 | 3251 | </div> |
| ... | ... | @@ -3764,6 +3766,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3764 | 3766 | activeProposedPlan={activeProposedPlan} |
| 3765 | 3767 | markdownCwd={gitCwd ?? undefined} |
| 3766 | 3768 | workspaceRoot={activeProject?.cwd ?? undefined} |
| 3769 | timestampFormat={timestampFormat} | |
| 3767 | 3770 | onClose={() => { |
| 3768 | 3771 | setPlanSidebarOpen(false); |
| 3769 | 3772 | // Track that the user explicitly dismissed for this turn so auto-open won't fight them. |
apps/web/src/components/DiffPanel.tsx+4-8| ... | ... | @@ -25,6 +25,8 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 25 | 25 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| 26 | 26 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 27 | 27 | import { useStore } from "../store"; |
| 28 | import { useAppSettings } from "../appSettings"; | |
| 29 | import { formatShortTimestamp } from "../timestampFormat"; | |
| 28 | 30 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 29 | 31 | |
| 30 | 32 | type DiffRenderMode = "stacked" | "split"; |
| ... | ... | @@ -149,13 +151,6 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { |
| 149 | 151 | return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; |
| 150 | 152 | } |
| 151 | 153 | |
| 152 | function formatTurnChipTimestamp(isoDate: string): string { | |
| 153 | return new Intl.DateTimeFormat(undefined, { | |
| 154 | hour: "numeric", | |
| 155 | minute: "2-digit", | |
| 156 | }).format(new Date(isoDate)); | |
| 157 | } | |
| 158 | ||
| 159 | 154 | interface DiffPanelProps { |
| 160 | 155 | mode?: "inline" | "sheet" | "sidebar"; |
| 161 | 156 | } |
| ... | ... | @@ -165,6 +160,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 165 | 160 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 166 | 161 | const navigate = useNavigate(); |
| 167 | 162 | const { resolvedTheme } = useTheme(); |
| 163 | const { settings } = useAppSettings(); | |
| 168 | 164 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 169 | 165 | const patchViewportRef = useRef<HTMLDivElement>(null); |
| 170 | 166 | const turnStripRef = useRef<HTMLDivElement>(null); |
| ... | ... | @@ -487,7 +483,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 487 | 483 | "?"} |
| 488 | 484 | </span> |
| 489 | 485 | <span className="text-[9px] leading-tight opacity-70"> |
| 490 | {formatTurnChipTimestamp(summary.completedAt)} | |
| 486 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} | |
| 491 | 487 | </span> |
| 492 | 488 | </div> |
| 493 | 489 | </div> |
apps/web/src/components/PlanSidebar.tsx+5-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { memo, useState, useCallback, useRef, useEffect } from "react"; |
| 2 | import { type TimestampFormat } from "../appSettings"; | |
| 2 | 3 | import { Badge } from "./ui/badge"; |
| 3 | 4 | import { Button } from "./ui/button"; |
| 4 | 5 | import { ScrollArea } from "./ui/scroll-area"; |
| ... | ... | @@ -12,9 +13,9 @@ import { |
| 12 | 13 | PanelRightCloseIcon, |
| 13 | 14 | } from "lucide-react"; |
| 14 | 15 | import { cn } from "~/lib/utils"; |
| 15 | import { formatTimestamp } from "../session-logic"; | |
| 16 | 16 | import type { ActivePlanState } from "../session-logic"; |
| 17 | 17 | import type { LatestProposedPlanState } from "../session-logic"; |
| 18 | import { formatTimestamp } from "../timestampFormat"; | |
| 18 | 19 | import { |
| 19 | 20 | proposedPlanTitle, |
| 20 | 21 | buildProposedPlanMarkdownFilename, |
| ... | ... | @@ -53,6 +54,7 @@ interface PlanSidebarProps { |
| 53 | 54 | activeProposedPlan: LatestProposedPlanState | null; |
| 54 | 55 | markdownCwd: string | undefined; |
| 55 | 56 | workspaceRoot: string | undefined; |
| 57 | timestampFormat: TimestampFormat; | |
| 56 | 58 | onClose: () => void; |
| 57 | 59 | } |
| 58 | 60 | |
| ... | ... | @@ -61,6 +63,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 61 | 63 | activeProposedPlan, |
| 62 | 64 | markdownCwd, |
| 63 | 65 | workspaceRoot, |
| 66 | timestampFormat, | |
| 64 | 67 | onClose, |
| 65 | 68 | }: PlanSidebarProps) { |
| 66 | 69 | const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); |
| ... | ... | @@ -145,7 +148,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 145 | 148 | </Badge> |
| 146 | 149 | {activePlan ? ( |
| 147 | 150 | <span className="text-[11px] text-muted-foreground/60"> |
| 148 | {formatTimestamp(activePlan.createdAt)} | |
| 151 | {formatTimestamp(activePlan.createdAt, timestampFormat)} | |
| 149 | 152 | </span> |
| 150 | 153 | ) : null} |
| 151 | 154 | </div> |
apps/web/src/components/chat/MessagesTimeline.tsx+14-5| ... | ... | @@ -5,7 +5,7 @@ import { |
| 5 | 5 | type VirtualItem, |
| 6 | 6 | useVirtualizer, |
| 7 | 7 | } from "@tanstack/react-virtual"; |
| 8 | import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic"; | |
| 8 | import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; | |
| 9 | 9 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; |
| 10 | 10 | import { type TurnDiffSummary } from "../../types"; |
| 11 | 11 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; |
| ... | ... | @@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 20 | 20 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 21 | 21 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 22 | 22 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; |
| 23 | import { type TimestampFormat } from "../../appSettings"; | |
| 24 | import { formatTimestamp } from "../../timestampFormat"; | |
| 23 | 25 | |
| 24 | 26 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 25 | 27 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; |
| ... | ... | @@ -44,6 +46,7 @@ interface MessagesTimelineProps { |
| 44 | 46 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 45 | 47 | markdownCwd: string | undefined; |
| 46 | 48 | resolvedTheme: "light" | "dark"; |
| 49 | timestampFormat: TimestampFormat; | |
| 47 | 50 | workspaceRoot: string | undefined; |
| 48 | 51 | } |
| 49 | 52 | |
| ... | ... | @@ -67,6 +70,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 67 | 70 | onImageExpand, |
| 68 | 71 | markdownCwd, |
| 69 | 72 | resolvedTheme, |
| 73 | timestampFormat, | |
| 70 | 74 | workspaceRoot, |
| 71 | 75 | }: MessagesTimelineProps) { |
| 72 | 76 | const timelineRootRef = useRef<HTMLDivElement | null>(null); |
| ... | ... | @@ -424,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 424 | 428 | )} |
| 425 | 429 | </div> |
| 426 | 430 | <p className="text-right text-[10px] text-muted-foreground/30"> |
| 427 | {formatTimestamp(row.message.createdAt)} | |
| 431 | {formatTimestamp(row.message.createdAt, timestampFormat)} | |
| 428 | 432 | </p> |
| 429 | 433 | </div> |
| 430 | 434 | </div> |
| ... | ... | @@ -515,6 +519,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 515 | 519 | row.message.streaming |
| 516 | 520 | ? formatElapsed(row.durationStart, nowIso) |
| 517 | 521 | : formatElapsed(row.durationStart, row.message.completedAt), |
| 522 | timestampFormat, | |
| 518 | 523 | )} |
| 519 | 524 | </p> |
| 520 | 525 | </div> |
| ... | ... | @@ -650,9 +655,13 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { |
| 650 | 655 | return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; |
| 651 | 656 | } |
| 652 | 657 | |
| 653 | function formatMessageMeta(createdAt: string, duration: string | null): string { | |
| 654 | if (!duration) return formatTimestamp(createdAt); | |
| 655 | return `${formatTimestamp(createdAt)} • ${duration}`; | |
| 658 | function formatMessageMeta( | |
| 659 | createdAt: string, | |
| 660 | duration: string | null, | |
| 661 | timestampFormat: TimestampFormat, | |
| 662 | ): string { | |
| 663 | if (!duration) return formatTimestamp(createdAt, timestampFormat); | |
| 664 | return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; | |
| 656 | 665 | } |
| 657 | 666 | |
| 658 | 667 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
apps/web/src/routes/_chat.settings.tsx+91-32| ... | ... | @@ -11,6 +11,13 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 11 | 11 | import { ensureNativeApi } from "../nativeApi"; |
| 12 | 12 | import { Button } from "../components/ui/button"; |
| 13 | 13 | import { Input } from "../components/ui/input"; |
| 14 | import { | |
| 15 | Select, | |
| 16 | SelectItem, | |
| 17 | SelectPopup, | |
| 18 | SelectTrigger, | |
| 19 | SelectValue, | |
| 20 | } from "../components/ui/select"; | |
| 14 | 21 | import { Switch } from "../components/ui/switch"; |
| 15 | 22 | import { APP_VERSION } from "../branding"; |
| 16 | 23 | import { SidebarInset } from "~/components/ui/sidebar"; |
| ... | ... | @@ -49,6 +56,12 @@ const MODEL_PROVIDER_SETTINGS: Array<{ |
| 49 | 56 | }, |
| 50 | 57 | ] as const; |
| 51 | 58 | |
| 59 | const TIMESTAMP_FORMAT_LABELS = { | |
| 60 | locale: "System default", | |
| 61 | "12-hour": "12-hour", | |
| 62 | "24-hour": "24-hour", | |
| 63 | } as const; | |
| 64 | ||
| 52 | 65 | function getCustomModelsForProvider( |
| 53 | 66 | settings: ReturnType<typeof useAppSettings>["settings"], |
| 54 | 67 | provider: ProviderKind, |
| ... | ... | @@ -210,43 +223,89 @@ function SettingsRouteView() { |
| 210 | 223 | <div className="mb-4"> |
| 211 | 224 | <h2 className="text-sm font-medium text-foreground">Appearance</h2> |
| 212 | 225 | <p className="mt-1 text-xs text-muted-foreground"> |
| 213 | Choose how T3 Code handles light and dark mode. | |
| 226 | Choose how T3 Code looks across the app. | |
| 214 | 227 | </p> |
| 215 | 228 | </div> |
| 216 | 229 | |
| 217 | <div className="space-y-2" role="radiogroup" aria-label="Theme preference"> | |
| 218 | {THEME_OPTIONS.map((option) => { | |
| 219 | const selected = theme === option.value; | |
| 220 | return ( | |
| 221 | <button | |
| 222 | key={option.value} | |
| 223 | type="button" | |
| 224 | role="radio" | |
| 225 | aria-checked={selected} | |
| 226 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 227 | selected | |
| 228 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 229 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 230 | }`} | |
| 231 | onClick={() => setTheme(option.value)} | |
| 232 | > | |
| 233 | <span className="flex flex-col"> | |
| 234 | <span className="text-sm font-medium">{option.label}</span> | |
| 235 | <span className="text-xs">{option.description}</span> | |
| 236 | </span> | |
| 237 | {selected ? ( | |
| 238 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 239 | Selected | |
| 230 | <div className="space-y-4"> | |
| 231 | <div className="space-y-2" role="radiogroup" aria-label="Theme preference"> | |
| 232 | {THEME_OPTIONS.map((option) => { | |
| 233 | const selected = theme === option.value; | |
| 234 | return ( | |
| 235 | <button | |
| 236 | key={option.value} | |
| 237 | type="button" | |
| 238 | role="radio" | |
| 239 | aria-checked={selected} | |
| 240 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 241 | selected | |
| 242 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 243 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 244 | }`} | |
| 245 | onClick={() => setTheme(option.value)} | |
| 246 | > | |
| 247 | <span className="flex flex-col"> | |
| 248 | <span className="text-sm font-medium">{option.label}</span> | |
| 249 | <span className="text-xs">{option.description}</span> | |
| 240 | 250 | </span> |
| 241 | ) : null} | |
| 242 | </button> | |
| 243 | ); | |
| 244 | })} | |
| 245 | </div> | |
| 251 | {selected ? ( | |
| 252 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 253 | Selected | |
| 254 | </span> | |
| 255 | ) : null} | |
| 256 | </button> | |
| 257 | ); | |
| 258 | })} | |
| 259 | </div> | |
| 246 | 260 | |
| 247 | <p className="mt-4 text-xs text-muted-foreground"> | |
| 248 | Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span> | |
| 249 | </p> | |
| 261 | <p className="text-xs text-muted-foreground"> | |
| 262 | Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span> | |
| 263 | </p> | |
| 264 | ||
| 265 | <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2"> | |
| 266 | <div> | |
| 267 | <p className="text-sm font-medium text-foreground">Timestamp format</p> | |
| 268 | <p className="text-xs text-muted-foreground"> | |
| 269 | System default follows your browser or OS time format. <code>12-hour</code>{" "} | |
| 270 | and <code>24-hour</code> force the hour cycle. | |
| 271 | </p> | |
| 272 | </div> | |
| 273 | <Select | |
| 274 | value={settings.timestampFormat} | |
| 275 | onValueChange={(value) => { | |
| 276 | if (value !== "locale" && value !== "12-hour" && value !== "24-hour") return; | |
| 277 | updateSettings({ | |
| 278 | timestampFormat: value, | |
| 279 | }); | |
| 280 | }} | |
| 281 | > | |
| 282 | <SelectTrigger className="w-40" aria-label="Timestamp format"> | |
| 283 | <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue> | |
| 284 | </SelectTrigger> | |
| 285 | <SelectPopup align="end"> | |
| 286 | <SelectItem value="locale">{TIMESTAMP_FORMAT_LABELS.locale}</SelectItem> | |
| 287 | <SelectItem value="12-hour">{TIMESTAMP_FORMAT_LABELS["12-hour"]}</SelectItem> | |
| 288 | <SelectItem value="24-hour">{TIMESTAMP_FORMAT_LABELS["24-hour"]}</SelectItem> | |
| 289 | </SelectPopup> | |
| 290 | </Select> | |
| 291 | </div> | |
| 292 | ||
| 293 | {settings.timestampFormat !== defaults.timestampFormat ? ( | |
| 294 | <div className="flex justify-end"> | |
| 295 | <Button | |
| 296 | size="xs" | |
| 297 | variant="outline" | |
| 298 | onClick={() => | |
| 299 | updateSettings({ | |
| 300 | timestampFormat: defaults.timestampFormat, | |
| 301 | }) | |
| 302 | } | |
| 303 | > | |
| 304 | Restore default | |
| 305 | </Button> | |
| 306 | </div> | |
| 307 | ) : null} | |
| 308 | </div> | |
| 250 | 309 | </section> |
| 251 | 310 | |
| 252 | 311 | <section className="rounded-2xl border border-border bg-card p-5"> |
apps/web/src/session-logic.ts-8| ... | ... | @@ -89,14 +89,6 @@ export type TimelineEntry = |
| 89 | 89 | entry: WorkLogEntry; |
| 90 | 90 | }; |
| 91 | 91 | |
| 92 | export function formatTimestamp(isoDate: string): string { | |
| 93 | return new Intl.DateTimeFormat(undefined, { | |
| 94 | hour: "numeric", | |
| 95 | minute: "2-digit", | |
| 96 | second: "2-digit", | |
| 97 | }).format(new Date(isoDate)); | |
| 98 | } | |
| 99 | ||
| 100 | 92 | export function formatDuration(durationMs: number): string { |
| 101 | 93 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; |
| 102 | 94 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; |
apps/web/src/timestampFormat.test.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { getTimestampFormatOptions } from "./timestampFormat"; | |
| 4 | ||
| 5 | describe("getTimestampFormatOptions", () => { | |
| 6 | it("omits hour12 when locale formatting is requested", () => { | |
| 7 | expect(getTimestampFormatOptions("locale", true)).toEqual({ | |
| 8 | hour: "numeric", | |
| 9 | minute: "2-digit", | |
| 10 | second: "2-digit", | |
| 11 | }); | |
| 12 | }); | |
| 13 | ||
| 14 | it("builds a 12-hour formatter with seconds when requested", () => { | |
| 15 | expect(getTimestampFormatOptions("12-hour", true)).toEqual({ | |
| 16 | hour: "numeric", | |
| 17 | minute: "2-digit", | |
| 18 | second: "2-digit", | |
| 19 | hour12: true, | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | it("builds a 24-hour formatter without seconds when requested", () => { | |
| 24 | expect(getTimestampFormatOptions("24-hour", false)).toEqual({ | |
| 25 | hour: "numeric", | |
| 26 | minute: "2-digit", | |
| 27 | hour12: false, | |
| 28 | }); | |
| 29 | }); | |
| 30 | }); |
apps/web/src/timestampFormat.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { type TimestampFormat } from "./appSettings"; | |
| 2 | ||
| 3 | export function getTimestampFormatOptions( | |
| 4 | timestampFormat: TimestampFormat, | |
| 5 | includeSeconds: boolean, | |
| 6 | ): Intl.DateTimeFormatOptions { | |
| 7 | const baseOptions: Intl.DateTimeFormatOptions = { | |
| 8 | hour: "numeric", | |
| 9 | minute: "2-digit", | |
| 10 | ...(includeSeconds ? { second: "2-digit" } : {}), | |
| 11 | }; | |
| 12 | ||
| 13 | if (timestampFormat === "locale") { | |
| 14 | return baseOptions; | |
| 15 | } | |
| 16 | ||
| 17 | return { | |
| 18 | ...baseOptions, | |
| 19 | hour12: timestampFormat === "12-hour", | |
| 20 | }; | |
| 21 | } | |
| 22 | ||
| 23 | const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>(); | |
| 24 | ||
| 25 | function getTimestampFormatter( | |
| 26 | timestampFormat: TimestampFormat, | |
| 27 | includeSeconds: boolean, | |
| 28 | ): Intl.DateTimeFormat { | |
| 29 | const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; | |
| 30 | const cachedFormatter = timestampFormatterCache.get(cacheKey); | |
| 31 | if (cachedFormatter) { | |
| 32 | return cachedFormatter; | |
| 33 | } | |
| 34 | ||
| 35 | const formatter = new Intl.DateTimeFormat( | |
| 36 | undefined, | |
| 37 | getTimestampFormatOptions(timestampFormat, includeSeconds), | |
| 38 | ); | |
| 39 | timestampFormatterCache.set(cacheKey, formatter); | |
| 40 | return formatter; | |
| 41 | } | |
| 42 | ||
| 43 | export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { | |
| 44 | return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate)); | |
| 45 | } | |
| 46 | ||
| 47 | export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { | |
| 48 | return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); | |
| 49 | } |