authorgravatar for 6117705+huxcrux@users.noreply.github.comHugo Blom <6117705+huxcrux@users.noreply.github.com> 2026-03-12 23:58:51+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 15:58:51-07:00
log727c0cfcbf1733edd9f23974f67c755ccbf6eee5
tree1f8018c9f73b2a71c8b7d554d5d038cbeb3c9b25
parentdcd2e5c34fbbf6cba7cdb23caef86568771a78d4
signature Signed by PGP key B5690EEEBB952194

feat: Allow Overriding Timestamp Format in Settings (#855)


10 files changed, 209 insertions(+), 55 deletions(-)

apps/web/src/appSettings.test.ts+7
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 DEFAULT_TIMESTAMP_FORMAT,
4 getAppModelOptions,5 getAppModelOptions,
5 normalizeCustomModelSlugs,6 normalizeCustomModelSlugs,
6 resolveAppModelSelection,7 resolveAppModelSelection,
...@@ -57,3 +58,9 @@ describe("resolveAppModelSelection", () => {...@@ -57,3 +58,9 @@ describe("resolveAppModelSelection", () => {
57 expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");58 expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");
58 });59 });
59});60});
61
62describe("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,6 +7,9 @@ import { useLocalStorage } from "./hooks/useLocalStorage";
7const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";7const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
8const MAX_CUSTOM_MODEL_COUNT = 32;8const MAX_CUSTOM_MODEL_COUNT = 32;
9export const MAX_CUSTOM_MODEL_LENGTH = 256;9export const MAX_CUSTOM_MODEL_LENGTH = 256;
10export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const;
11export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
12export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
10const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {13const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
11 codex: new Set(getModelOptions("codex").map((option) => option.slug)),14 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
12};15};
...@@ -22,6 +25,9 @@ const AppSettingsSchema = Schema.Struct({...@@ -22,6 +25,9 @@ const AppSettingsSchema = Schema.Struct({
22 enableAssistantStreaming: Schema.Boolean.pipe(25 enableAssistantStreaming: Schema.Boolean.pipe(
23 Schema.withConstructorDefault(() => Option.some(false)),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 customCodexModels: Schema.Array(Schema.String).pipe(31 customCodexModels: Schema.Array(Schema.String).pipe(
26 Schema.withConstructorDefault(() => Option.some([])),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,6 +184,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
184 const setStoreThreadError = useStore((store) => store.setError);184 const setStoreThreadError = useStore((store) => store.setError);
185 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);185 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
186 const { settings } = useAppSettings();186 const { settings } = useAppSettings();
187 const timestampFormat = settings.timestampFormat;
187 const navigate = useNavigate();188 const navigate = useNavigate();
188 const rawSearch = useSearch({189 const rawSearch = useSearch({
189 strict: false,190 strict: false,
...@@ -3244,6 +3245,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3244,6 +3245,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3244 onImageExpand={onExpandTimelineImage}3245 onImageExpand={onExpandTimelineImage}
3245 markdownCwd={gitCwd ?? undefined}3246 markdownCwd={gitCwd ?? undefined}
3246 resolvedTheme={resolvedTheme}3247 resolvedTheme={resolvedTheme}
3248 timestampFormat={timestampFormat}
3247 workspaceRoot={activeProject?.cwd ?? undefined}3249 workspaceRoot={activeProject?.cwd ?? undefined}
3248 />3250 />
3249 </div>3251 </div>
...@@ -3764,6 +3766,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3764,6 +3766,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3764 activeProposedPlan={activeProposedPlan}3766 activeProposedPlan={activeProposedPlan}
3765 markdownCwd={gitCwd ?? undefined}3767 markdownCwd={gitCwd ?? undefined}
3766 workspaceRoot={activeProject?.cwd ?? undefined}3768 workspaceRoot={activeProject?.cwd ?? undefined}
3769 timestampFormat={timestampFormat}
3767 onClose={() => {3770 onClose={() => {
3768 setPlanSidebarOpen(false);3771 setPlanSidebarOpen(false);
3769 // Track that the user explicitly dismissed for this turn so auto-open won't fight them.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,6 +25,8 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
25import { resolveDiffThemeName } from "../lib/diffRendering";25import { resolveDiffThemeName } from "../lib/diffRendering";
26import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";26import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
27import { useStore } from "../store";27import { useStore } from "../store";
28import { useAppSettings } from "../appSettings";
29import { formatShortTimestamp } from "../timestampFormat";
28import { ToggleGroup, Toggle } from "./ui/toggle-group";30import { ToggleGroup, Toggle } from "./ui/toggle-group";
2931
30type DiffRenderMode = "stacked" | "split";32type DiffRenderMode = "stacked" | "split";
...@@ -149,13 +151,6 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {...@@ -149,13 +151,6 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
149 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;151 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
150}152}
151153
152function formatTurnChipTimestamp(isoDate: string): string {
153 return new Intl.DateTimeFormat(undefined, {
154 hour: "numeric",
155 minute: "2-digit",
156 }).format(new Date(isoDate));
157}
158
159interface DiffPanelProps {154interface DiffPanelProps {
160 mode?: "inline" | "sheet" | "sidebar";155 mode?: "inline" | "sheet" | "sidebar";
161}156}
...@@ -165,6 +160,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";...@@ -165,6 +160,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
165export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {160export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
166 const navigate = useNavigate();161 const navigate = useNavigate();
167 const { resolvedTheme } = useTheme();162 const { resolvedTheme } = useTheme();
163 const { settings } = useAppSettings();
168 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");164 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
169 const patchViewportRef = useRef<HTMLDivElement>(null);165 const patchViewportRef = useRef<HTMLDivElement>(null);
170 const turnStripRef = useRef<HTMLDivElement>(null);166 const turnStripRef = useRef<HTMLDivElement>(null);
...@@ -487,7 +483,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -487,7 +483,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
487 "?"}483 "?"}
488 </span>484 </span>
489 <span className="text-[9px] leading-tight opacity-70">485 <span className="text-[9px] leading-tight opacity-70">
490 {formatTurnChipTimestamp(summary.completedAt)}486 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
491 </span>487 </span>
492 </div>488 </div>
493 </div>489 </div>
apps/web/src/components/PlanSidebar.tsx+5-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { memo, useState, useCallback, useRef, useEffect } from "react";1import { memo, useState, useCallback, useRef, useEffect } from "react";
2import { type TimestampFormat } from "../appSettings";
2import { Badge } from "./ui/badge";3import { Badge } from "./ui/badge";
3import { Button } from "./ui/button";4import { Button } from "./ui/button";
4import { ScrollArea } from "./ui/scroll-area";5import { ScrollArea } from "./ui/scroll-area";
...@@ -12,9 +13,9 @@ import {...@@ -12,9 +13,9 @@ import {
12 PanelRightCloseIcon,13 PanelRightCloseIcon,
13} from "lucide-react";14} from "lucide-react";
14import { cn } from "~/lib/utils";15import { cn } from "~/lib/utils";
15import { formatTimestamp } from "../session-logic";
16import type { ActivePlanState } from "../session-logic";16import type { ActivePlanState } from "../session-logic";
17import type { LatestProposedPlanState } from "../session-logic";17import type { LatestProposedPlanState } from "../session-logic";
18import { formatTimestamp } from "../timestampFormat";
18import {19import {
19 proposedPlanTitle,20 proposedPlanTitle,
20 buildProposedPlanMarkdownFilename,21 buildProposedPlanMarkdownFilename,
...@@ -53,6 +54,7 @@ interface PlanSidebarProps {...@@ -53,6 +54,7 @@ interface PlanSidebarProps {
53 activeProposedPlan: LatestProposedPlanState | null;54 activeProposedPlan: LatestProposedPlanState | null;
54 markdownCwd: string | undefined;55 markdownCwd: string | undefined;
55 workspaceRoot: string | undefined;56 workspaceRoot: string | undefined;
57 timestampFormat: TimestampFormat;
56 onClose: () => void;58 onClose: () => void;
57}59}
5860
...@@ -61,6 +63,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -61,6 +63,7 @@ const PlanSidebar = memo(function PlanSidebar({
61 activeProposedPlan,63 activeProposedPlan,
62 markdownCwd,64 markdownCwd,
63 workspaceRoot,65 workspaceRoot,
66 timestampFormat,
64 onClose,67 onClose,
65}: PlanSidebarProps) {68}: PlanSidebarProps) {
66 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);69 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
...@@ -145,7 +148,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -145,7 +148,7 @@ const PlanSidebar = memo(function PlanSidebar({
145 </Badge>148 </Badge>
146 {activePlan ? (149 {activePlan ? (
147 <span className="text-[11px] text-muted-foreground/60">150 <span className="text-[11px] text-muted-foreground/60">
148 {formatTimestamp(activePlan.createdAt)}151 {formatTimestamp(activePlan.createdAt, timestampFormat)}
149 </span>152 </span>
150 ) : null}153 ) : null}
151 </div>154 </div>
apps/web/src/components/chat/MessagesTimeline.tsx+14-5
...@@ -5,7 +5,7 @@ import {...@@ -5,7 +5,7 @@ import {
5 type VirtualItem,5 type VirtualItem,
6 useVirtualizer,6 useVirtualizer,
7} from "@tanstack/react-virtual";7} from "@tanstack/react-virtual";
8import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic";8import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
9import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";9import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
10import { type TurnDiffSummary } from "../../types";10import { type TurnDiffSummary } from "../../types";
11import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";11import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
...@@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree";...@@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree";
20import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";20import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
21import { MessageCopyButton } from "./MessageCopyButton";21import { MessageCopyButton } from "./MessageCopyButton";
22import { computeMessageDurationStart } from "./MessagesTimeline.logic";22import { computeMessageDurationStart } from "./MessagesTimeline.logic";
23import { type TimestampFormat } from "../../appSettings";
24import { formatTimestamp } from "../../timestampFormat";
2325
24const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;26const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
25const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;27const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
...@@ -44,6 +46,7 @@ interface MessagesTimelineProps {...@@ -44,6 +46,7 @@ interface MessagesTimelineProps {
44 onImageExpand: (preview: ExpandedImagePreview) => void;46 onImageExpand: (preview: ExpandedImagePreview) => void;
45 markdownCwd: string | undefined;47 markdownCwd: string | undefined;
46 resolvedTheme: "light" | "dark";48 resolvedTheme: "light" | "dark";
49 timestampFormat: TimestampFormat;
47 workspaceRoot: string | undefined;50 workspaceRoot: string | undefined;
48}51}
4952
...@@ -67,6 +70,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -67,6 +70,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
67 onImageExpand,70 onImageExpand,
68 markdownCwd,71 markdownCwd,
69 resolvedTheme,72 resolvedTheme,
73 timestampFormat,
70 workspaceRoot,74 workspaceRoot,
71}: MessagesTimelineProps) {75}: MessagesTimelineProps) {
72 const timelineRootRef = useRef<HTMLDivElement | null>(null);76 const timelineRootRef = useRef<HTMLDivElement | null>(null);
...@@ -424,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -424,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
424 )}428 )}
425 </div>429 </div>
426 <p className="text-right text-[10px] text-muted-foreground/30">430 <p className="text-right text-[10px] text-muted-foreground/30">
427 {formatTimestamp(row.message.createdAt)}431 {formatTimestamp(row.message.createdAt, timestampFormat)}
428 </p>432 </p>
429 </div>433 </div>
430 </div>434 </div>
...@@ -515,6 +519,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -515,6 +519,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
515 row.message.streaming519 row.message.streaming
516 ? formatElapsed(row.durationStart, nowIso)520 ? formatElapsed(row.durationStart, nowIso)
517 : formatElapsed(row.durationStart, row.message.completedAt),521 : formatElapsed(row.durationStart, row.message.completedAt),
522 timestampFormat,
518 )}523 )}
519 </p>524 </p>
520 </div>525 </div>
...@@ -650,9 +655,13 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null {...@@ -650,9 +655,13 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null {
650 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;655 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
651}656}
652657
653function formatMessageMeta(createdAt: string, duration: string | null): string {658function formatMessageMeta(
654 if (!duration) return formatTimestamp(createdAt);659 createdAt: string,
655 return `${formatTimestamp(createdAt)} • ${duration}`;660 duration: string | null,
661 timestampFormat: TimestampFormat,
662): string {
663 if (!duration) return formatTimestamp(createdAt, timestampFormat);
664 return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;
656}665}
657666
658function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {667function 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,6 +11,13 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
11import { ensureNativeApi } from "../nativeApi";11import { ensureNativeApi } from "../nativeApi";
12import { Button } from "../components/ui/button";12import { Button } from "../components/ui/button";
13import { Input } from "../components/ui/input";13import { Input } from "../components/ui/input";
14import {
15 Select,
16 SelectItem,
17 SelectPopup,
18 SelectTrigger,
19 SelectValue,
20} from "../components/ui/select";
14import { Switch } from "../components/ui/switch";21import { Switch } from "../components/ui/switch";
15import { APP_VERSION } from "../branding";22import { APP_VERSION } from "../branding";
16import { SidebarInset } from "~/components/ui/sidebar";23import { SidebarInset } from "~/components/ui/sidebar";
...@@ -49,6 +56,12 @@ const MODEL_PROVIDER_SETTINGS: Array<{...@@ -49,6 +56,12 @@ const MODEL_PROVIDER_SETTINGS: Array<{
49 },56 },
50] as const;57] as const;
5158
59const TIMESTAMP_FORMAT_LABELS = {
60 locale: "System default",
61 "12-hour": "12-hour",
62 "24-hour": "24-hour",
63} as const;
64
52function getCustomModelsForProvider(65function getCustomModelsForProvider(
53 settings: ReturnType<typeof useAppSettings>["settings"],66 settings: ReturnType<typeof useAppSettings>["settings"],
54 provider: ProviderKind,67 provider: ProviderKind,
...@@ -210,43 +223,89 @@ function SettingsRouteView() {...@@ -210,43 +223,89 @@ function SettingsRouteView() {
210 <div className="mb-4">223 <div className="mb-4">
211 <h2 className="text-sm font-medium text-foreground">Appearance</h2>224 <h2 className="text-sm font-medium text-foreground">Appearance</h2>
212 <p className="mt-1 text-xs text-muted-foreground">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 </p>227 </p>
215 </div>228 </div>
216229
217 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">230 <div className="space-y-4">
218 {THEME_OPTIONS.map((option) => {231 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
219 const selected = theme === option.value;232 {THEME_OPTIONS.map((option) => {
220 return (233 const selected = theme === option.value;
221 <button234 return (
222 key={option.value}235 <button
223 type="button"236 key={option.value}
224 role="radio"237 type="button"
225 aria-checked={selected}238 role="radio"
226 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${239 aria-checked={selected}
227 selected240 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
228 ? "border-primary/60 bg-primary/8 text-foreground"241 selected
229 : "border-border bg-background text-muted-foreground hover:bg-accent"242 ? "border-primary/60 bg-primary/8 text-foreground"
230 }`}243 : "border-border bg-background text-muted-foreground hover:bg-accent"
231 onClick={() => setTheme(option.value)}244 }`}
232 >245 onClick={() => setTheme(option.value)}
233 <span className="flex flex-col">246 >
234 <span className="text-sm font-medium">{option.label}</span>247 <span className="flex flex-col">
235 <span className="text-xs">{option.description}</span>248 <span className="text-sm font-medium">{option.label}</span>
236 </span>249 <span className="text-xs">{option.description}</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
240 </span>250 </span>
241 ) : null}251 {selected ? (
242 </button>252 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
243 );253 Selected
244 })}254 </span>
245 </div>255 ) : null}
256 </button>
257 );
258 })}
259 </div>
246260
247 <p className="mt-4 text-xs text-muted-foreground">261 <p className="text-xs text-muted-foreground">
248 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>262 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
249 </p>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 </section>309 </section>
251310
252 <section className="rounded-2xl border border-border bg-card p-5">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,14 +89,6 @@ export type TimelineEntry =
89 entry: WorkLogEntry;89 entry: WorkLogEntry;
90 };90 };
9191
92export 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
100export function formatDuration(durationMs: number): string {92export function formatDuration(durationMs: number): string {
101 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";93 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
102 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;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 @@
1import { describe, expect, it } from "vitest";
2
3import { getTimestampFormatOptions } from "./timestampFormat";
4
5describe("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 @@
1import { type TimestampFormat } from "./appSettings";
2
3export 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
23const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
24
25function 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
43export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
44 return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));
45}
46
47export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
48 return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));
49}