| 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,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | |||
| 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,6 +7,9 @@ import { useLocalStorage } from "./hooks/useLocalStorage"; |
| 7 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | 7 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 8 | const MAX_CUSTOM_MODEL_COUNT = 32; | 8 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 9 | export const MAX_CUSTOM_MODEL_LENGTH = 256; | 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 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { | 13 | const 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"; |
| 25 | import { resolveDiffThemeName } from "../lib/diffRendering"; | 25 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| 26 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 26 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 27 | import { useStore } from "../store"; | 27 | import { useStore } from "../store"; |
| 28 | import { useAppSettings } from "../appSettings"; | ||
| 29 | import { formatShortTimestamp } from "../timestampFormat"; | ||
| 28 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | 30 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 29 | 31 | ||
| 30 | type DiffRenderMode = "stacked" | "split"; | 32 | type 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 | } |
| 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 | interface DiffPanelProps { | 154 | interface 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"; |
| 165 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | 160 | export 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 @@ |
| 1 | import { memo, useState, useCallback, useRef, useEffect } from "react"; | 1 | import { memo, useState, useCallback, useRef, useEffect } from "react"; |
| 2 | import { type TimestampFormat } from "../appSettings"; | ||
| 2 | import { Badge } from "./ui/badge"; | 3 | import { Badge } from "./ui/badge"; |
| 3 | import { Button } from "./ui/button"; | 4 | import { Button } from "./ui/button"; |
| 4 | import { ScrollArea } from "./ui/scroll-area"; | 5 | import { 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"; |
| 14 | import { cn } from "~/lib/utils"; | 15 | import { cn } from "~/lib/utils"; |
| 15 | import { formatTimestamp } from "../session-logic"; | ||
| 16 | import type { ActivePlanState } from "../session-logic"; | 16 | import type { ActivePlanState } from "../session-logic"; |
| 17 | import type { LatestProposedPlanState } from "../session-logic"; | 17 | import type { LatestProposedPlanState } from "../session-logic"; |
| 18 | import { formatTimestamp } from "../timestampFormat"; | ||
| 18 | import { | 19 | import { |
| 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 | } |
| 58 | 60 | ||
| ... | @@ -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"; |
| 8 | import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic"; | 8 | import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; |
| 9 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; | 9 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; |
| 10 | import { type TurnDiffSummary } from "../../types"; | 10 | import { type TurnDiffSummary } from "../../types"; |
| 11 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | 11 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; |
| ... | @@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; | ... | @@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 20 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 20 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 21 | import { MessageCopyButton } from "./MessageCopyButton"; | 21 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 22 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; | 22 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; |
| 23 | import { type TimestampFormat } from "../../appSettings"; | ||
| 24 | import { formatTimestamp } from "../../timestampFormat"; | ||
| 23 | 25 | ||
| 24 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 26 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 25 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; | 27 | const 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 | } |
| 49 | 52 | ||
| ... | @@ -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.streaming | 519 | 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 | } |
| 652 | 657 | ||
| 653 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 658 | function 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 | } |
| 657 | 666 | ||
| 658 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 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,6 +11,13 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 11 | import { ensureNativeApi } from "../nativeApi"; | 11 | import { ensureNativeApi } from "../nativeApi"; |
| 12 | import { Button } from "../components/ui/button"; | 12 | import { Button } from "../components/ui/button"; |
| 13 | import { Input } from "../components/ui/input"; | 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 | import { Switch } from "../components/ui/switch"; | 21 | import { Switch } from "../components/ui/switch"; |
| 15 | import { APP_VERSION } from "../branding"; | 22 | import { APP_VERSION } from "../branding"; |
| 16 | import { SidebarInset } from "~/components/ui/sidebar"; | 23 | import { 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; |
| 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 | function getCustomModelsForProvider( | 65 | function 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> |
| 216 | 229 | ||
| 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 | <button | 234 | 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 | selected | 240 | 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> | ||
| 246 | 260 | ||
| 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> |
| 251 | 310 | ||
| 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 | }; |
| 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 | export function formatDuration(durationMs: number): string { | 92 | export 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 @@ | ||
| 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 | } | ||