| author | |
| committer | |
| log | 2daff8c25adf701fddd062ae93b94cc57d420ec2 |
| tree | 19827171d8e6b927957872545afe44880480732c |
| parent | 8dcb96314c976899e4df6951fb9af03131c2a46f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>13 files changed, 3 insertions(+), 282 deletions(-)
apps/web/src/appearanceFonts.test.ts-15| ... | @@ -5,9 +5,6 @@ import { | ... | @@ -5,9 +5,6 @@ import { |
| 5 | clampCodeFontSize, | 5 | clampCodeFontSize, |
| 6 | clampInterfaceFontSize, | 6 | clampInterfaceFontSize, |
| 7 | clampPromptFontSize, | 7 | clampPromptFontSize, |
| 8 | DEFAULT_CODE_FONT_STACK, | ||
| 9 | DEFAULT_SANS_FONT_STACK, | ||
| 10 | appearanceFontStack, | ||
| 11 | cssFontFamilies, | 8 | cssFontFamilies, |
| 12 | resolveDefaultFamilyLabel, | 9 | resolveDefaultFamilyLabel, |
| 13 | resolveTerminalFontPreference, | 10 | resolveTerminalFontPreference, |
| ... | @@ -58,18 +55,6 @@ describe("resolveDefaultFamilyLabel", () => { | ... | @@ -58,18 +55,6 @@ describe("resolveDefaultFamilyLabel", () => { |
| 58 | }); | 55 | }); |
| 59 | }); | 56 | }); |
| 60 | 57 | ||
| 61 | describe("appearanceFontStack", () => { | ||
| 62 | it("prepends the custom family to the default stack", () => { | ||
| 63 | expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( | ||
| 64 | `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, | ||
| 65 | ); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("falls back to the default stack when unset", () => { | ||
| 69 | expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); | ||
| 70 | }); | ||
| 71 | }); | ||
| 72 | |||
| 73 | describe("resolveTerminalFontPreference", () => { | 58 | describe("resolveTerminalFontPreference", () => { |
| 74 | it("inherits the code font in simple mode", () => { | 59 | it("inherits the code font in simple mode", () => { |
| 75 | expect( | 60 | expect( |
apps/web/src/appearanceFonts.ts-6| ... | @@ -71,12 +71,6 @@ export function cssFontFamilies(input: string): string | null { | ... | @@ -71,12 +71,6 @@ export function cssFontFamilies(input: string): string | null { |
| 71 | return families.length > 0 ? families.join(", ") : null; | 71 | return families.length > 0 ? families.join(", ") : null; |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | /** The full stack a preference resolves to: custom families before the default. */ | ||
| 75 | export function appearanceFontStack(custom: string, defaultStack: string): string { | ||
| 76 | const families = cssFontFamilies(custom); | ||
| 77 | return families === null ? defaultStack : `${families}, ${defaultStack}`; | ||
| 78 | } | ||
| 79 | |||
| 80 | export interface AppearanceFontPreferences { | 74 | export interface AppearanceFontPreferences { |
| 81 | readonly sans: string; | 75 | readonly sans: string; |
| 82 | readonly code: string; | 76 | readonly code: string; |
apps/web/src/components/Sidebar.logic.test.ts-39| ... | @@ -20,7 +20,6 @@ import { | ... | @@ -20,7 +20,6 @@ import { |
| 20 | isTrailingDoubleClick, | 20 | isTrailingDoubleClick, |
| 21 | orderItemsByPreferredIds, | 21 | orderItemsByPreferredIds, |
| 22 | resolveProjectStatusIndicator, | 22 | resolveProjectStatusIndicator, |
| 23 | resolveSidebarStageBadgeLabel, | ||
| 24 | resolveThreadRowClassName, | 23 | resolveThreadRowClassName, |
| 25 | resolveSidebarThreadStatus, | 24 | resolveSidebarThreadStatus, |
| 26 | resolveThreadStatusPill, | 25 | resolveThreadStatusPill, |
| ... | @@ -236,44 +235,6 @@ describe("buildMultiSelectThreadContextMenuItems", () => { | ... | @@ -236,44 +235,6 @@ describe("buildMultiSelectThreadContextMenuItems", () => { |
| 236 | }); | 235 | }); |
| 237 | }); | 236 | }); |
| 238 | 237 | ||
| 239 | describe("resolveSidebarStageBadgeLabel", () => { | ||
| 240 | it("returns Nightly for nightly primary server versions", () => { | ||
| 241 | expect( | ||
| 242 | resolveSidebarStageBadgeLabel({ | ||
| 243 | primaryServerVersion: "0.0.28-nightly.20260616.12", | ||
| 244 | fallbackStageLabel: "Alpha", | ||
| 245 | }), | ||
| 246 | ).toBe("Nightly"); | ||
| 247 | }); | ||
| 248 | |||
| 249 | it("returns the fallback label for stable primary server versions", () => { | ||
| 250 | expect( | ||
| 251 | resolveSidebarStageBadgeLabel({ | ||
| 252 | primaryServerVersion: "0.0.27", | ||
| 253 | fallbackStageLabel: "Alpha", | ||
| 254 | }), | ||
| 255 | ).toBe("Alpha"); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("returns the fallback label when the primary server version is missing", () => { | ||
| 259 | expect( | ||
| 260 | resolveSidebarStageBadgeLabel({ | ||
| 261 | primaryServerVersion: null, | ||
| 262 | fallbackStageLabel: "Dev", | ||
| 263 | }), | ||
| 264 | ).toBe("Dev"); | ||
| 265 | }); | ||
| 266 | |||
| 267 | it("returns the fallback label for malformed nightly prerelease versions", () => { | ||
| 268 | expect( | ||
| 269 | resolveSidebarStageBadgeLabel({ | ||
| 270 | primaryServerVersion: "0.0.28-nightly.20260616", | ||
| 271 | fallbackStageLabel: "Alpha", | ||
| 272 | }), | ||
| 273 | ).toBe("Alpha"); | ||
| 274 | }); | ||
| 275 | }); | ||
| 276 | |||
| 277 | function makeLatestTurn(overrides?: { | 238 | function makeLatestTurn(overrides?: { |
| 278 | completedAt?: string | null; | 239 | completedAt?: string | null; |
| 279 | startedAt?: string | null; | 240 | startedAt?: string | null; |
apps/web/src/components/Sidebar.logic.ts-8| ... | @@ -13,7 +13,6 @@ import type { SidebarThreadSummary, Thread } from "../types"; | ... | @@ -13,7 +13,6 @@ import type { SidebarThreadSummary, Thread } from "../types"; |
| 13 | import type { ThreadRouteTarget } from "../threadRoutes"; | 13 | import type { ThreadRouteTarget } from "../threadRoutes"; |
| 14 | import { cn } from "../lib/utils"; | 14 | import { cn } from "../lib/utils"; |
| 15 | import { isLatestTurnSettled } from "../session-logic"; | 15 | import { isLatestTurnSettled } from "../session-logic"; |
| 16 | import { resolveServerBackedAppStageLabel } from "../branding.logic"; | ||
| 17 | 16 | ||
| 18 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | 17 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 19 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; | 18 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; |
| ... | @@ -170,13 +169,6 @@ export interface ThreadJumpHintVisibilityController { | ... | @@ -170,13 +169,6 @@ export interface ThreadJumpHintVisibilityController { |
| 170 | dispose: () => void; | 169 | dispose: () => void; |
| 171 | } | 170 | } |
| 172 | 171 | ||
| 173 | export function resolveSidebarStageBadgeLabel(input: { | ||
| 174 | primaryServerVersion: string | null | undefined; | ||
| 175 | fallbackStageLabel: string; | ||
| 176 | }): string { | ||
| 177 | return resolveServerBackedAppStageLabel(input); | ||
| 178 | } | ||
| 179 | |||
| 180 | export function createThreadJumpHintVisibilityController(input: { | 172 | export function createThreadJumpHintVisibilityController(input: { |
| 181 | delayMs: number; | 173 | delayMs: number; |
| 182 | onVisibilityChange: (visible: boolean) => void; | 174 | onVisibilityChange: (visible: boolean) => void; |
apps/web/src/pendingUserInput.test.ts-24| ... | @@ -4,7 +4,6 @@ import { | ... | @@ -4,7 +4,6 @@ import { |
| 4 | buildPendingUserInputAnswers, | 4 | buildPendingUserInputAnswers, |
| 5 | countAnsweredPendingUserInputQuestions, | 5 | countAnsweredPendingUserInputQuestions, |
| 6 | derivePendingUserInputProgress, | 6 | derivePendingUserInputProgress, |
| 7 | findFirstUnansweredPendingUserInputQuestionIndex, | ||
| 8 | resolvePendingUserInputAnswer, | 7 | resolvePendingUserInputAnswer, |
| 9 | setPendingUserInputCustomAnswer, | 8 | setPendingUserInputCustomAnswer, |
| 10 | togglePendingUserInputOptionSelection, | 9 | togglePendingUserInputOptionSelection, |
| ... | @@ -182,29 +181,6 @@ describe("pending user input question progress", () => { | ... | @@ -182,29 +181,6 @@ describe("pending user input question progress", () => { |
| 182 | ).toBe(1); | 181 | ).toBe(1); |
| 183 | }); | 182 | }); |
| 184 | 183 | ||
| 185 | it("finds the first unanswered question", () => { | ||
| 186 | expect( | ||
| 187 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { | ||
| 188 | scope: { | ||
| 189 | selectedOptionLabels: ["Orchestration-first"], | ||
| 190 | }, | ||
| 191 | }), | ||
| 192 | ).toBe(1); | ||
| 193 | }); | ||
| 194 | |||
| 195 | it("returns the last question index when all answers are complete", () => { | ||
| 196 | expect( | ||
| 197 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { | ||
| 198 | scope: { | ||
| 199 | selectedOptionLabels: ["Orchestration-first"], | ||
| 200 | }, | ||
| 201 | compat: { | ||
| 202 | customAnswer: "Keep it for one release window", | ||
| 203 | }, | ||
| 204 | }), | ||
| 205 | ).toBe(1); | ||
| 206 | }); | ||
| 207 | |||
| 208 | it("derives the active question and advancement state", () => { | 184 | it("derives the active question and advancement state", () => { |
| 209 | expect( | 185 | expect( |
| 210 | derivePendingUserInputProgress( | 186 | derivePendingUserInputProgress( |
apps/web/src/pendingUserInput.ts-11| ... | @@ -128,17 +128,6 @@ export function countAnsweredPendingUserInputQuestions( | ... | @@ -128,17 +128,6 @@ export function countAnsweredPendingUserInputQuestions( |
| 128 | }, 0); | 128 | }, 0); |
| 129 | } | 129 | } |
| 130 | 130 | ||
| 131 | export function findFirstUnansweredPendingUserInputQuestionIndex( | ||
| 132 | questions: ReadonlyArray<UserInputQuestion>, | ||
| 133 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | ||
| 134 | ): number { | ||
| 135 | const unansweredIndex = questions.findIndex( | ||
| 136 | (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), | ||
| 137 | ); | ||
| 138 | |||
| 139 | return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; | ||
| 140 | } | ||
| 141 | |||
| 142 | export function derivePendingUserInputProgress( | 131 | export function derivePendingUserInputProgress( |
| 143 | questions: ReadonlyArray<UserInputQuestion>, | 132 | questions: ReadonlyArray<UserInputQuestion>, |
| 144 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | 133 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
apps/web/src/providerUpdateDismissal.test.ts deleted-23| ... | @@ -1,23 +0,0 @@ | ||
| 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { removeLocalStorageItem } from "./hooks/useLocalStorage"; | ||
| 4 | import { | ||
| 5 | dismissProviderUpdateNotification, | ||
| 6 | isProviderUpdateNotificationDismissed, | ||
| 7 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | ||
| 8 | } from "./providerUpdateDismissal"; | ||
| 9 | |||
| 10 | describe("providerUpdateDismissal", () => { | ||
| 11 | beforeEach(() => { | ||
| 12 | removeLocalStorageItem(PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("persists provider update dismissals by notification key", () => { | ||
| 16 | expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(false); | ||
| 17 | |||
| 18 | dismissProviderUpdateNotification("opencode:1.14.33"); | ||
| 19 | |||
| 20 | expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(true); | ||
| 21 | expect(isProviderUpdateNotificationDismissed("opencode:1.14.34")).toBe(false); | ||
| 22 | }); | ||
| 23 | }); | ||
apps/web/src/providerUpdateDismissal.ts+2-55| ... | @@ -1,67 +1,14 @@ | ... | @@ -1,67 +1,14 @@ |
| 1 | import { useCallback, useMemo } from "react"; | 1 | import { useCallback, useMemo } from "react"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; | 4 | import { useLocalStorage } from "./hooks/useLocalStorage"; |
| 5 | 5 | ||
| 6 | export const PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY = "t3code:provider-update-dismissals:v1"; | 6 | const PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY = "t3code:provider-update-dismissals:v1"; |
| 7 | 7 | ||
| 8 | const ProviderUpdateDismissalsSchema = Schema.Struct({ | 8 | const ProviderUpdateDismissalsSchema = Schema.Struct({ |
| 9 | keys: Schema.Array(Schema.String), | 9 | keys: Schema.Array(Schema.String), |
| 10 | }); | 10 | }); |
| 11 | 11 | ||
| 12 | type ProviderUpdateDismissals = typeof ProviderUpdateDismissalsSchema.Type; | ||
| 13 | |||
| 14 | function readProviderUpdateDismissals(): ProviderUpdateDismissals { | ||
| 15 | try { | ||
| 16 | return ( | ||
| 17 | getLocalStorageItem( | ||
| 18 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | ||
| 19 | ProviderUpdateDismissalsSchema, | ||
| 20 | ) ?? { | ||
| 21 | keys: [], | ||
| 22 | } | ||
| 23 | ); | ||
| 24 | } catch (error) { | ||
| 25 | console.error("Could not read provider-update dismissals.", error); | ||
| 26 | return { keys: [] }; | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void { | ||
| 31 | try { | ||
| 32 | setLocalStorageItem( | ||
| 33 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | ||
| 34 | document, | ||
| 35 | ProviderUpdateDismissalsSchema, | ||
| 36 | ); | ||
| 37 | } catch (error) { | ||
| 38 | console.error("Could not persist provider-update dismissals.", error); | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | export function isProviderUpdateNotificationDismissed( | ||
| 43 | dismissalKey: string | null | undefined, | ||
| 44 | ): boolean { | ||
| 45 | if (!dismissalKey) { | ||
| 46 | return false; | ||
| 47 | } | ||
| 48 | return readProviderUpdateDismissals().keys.includes(dismissalKey); | ||
| 49 | } | ||
| 50 | |||
| 51 | export function dismissProviderUpdateNotification(dismissalKey: string | null | undefined): void { | ||
| 52 | const trimmedKey = dismissalKey?.trim(); | ||
| 53 | if (!trimmedKey) { | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | const document = readProviderUpdateDismissals(); | ||
| 57 | if (document.keys.includes(trimmedKey)) { | ||
| 58 | return; | ||
| 59 | } | ||
| 60 | writeProviderUpdateDismissals({ | ||
| 61 | keys: [...document.keys, trimmedKey], | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | |||
| 65 | export function useDismissedProviderUpdateNotificationKeys() { | 12 | export function useDismissedProviderUpdateNotificationKeys() { |
| 66 | const [dismissals, setDismissals] = useLocalStorage( | 13 | const [dismissals, setDismissals] = useLocalStorage( |
| 67 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | 14 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, |
apps/web/src/threadSync.test.ts+1-8| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; | 3 | import { resolveThreadSyncPhase } from "./threadSync"; |
| 4 | 4 | ||
| 5 | describe("resolveThreadSyncPhase", () => { | 5 | describe("resolveThreadSyncPhase", () => { |
| 6 | it("loads when only shell data is available", () => { | 6 | it("loads when only shell data is available", () => { |
| ... | @@ -40,10 +40,3 @@ describe("resolveThreadSyncPhase", () => { | ... | @@ -40,10 +40,3 @@ describe("resolveThreadSyncPhase", () => { |
| 40 | ).toBeNull(); | 40 | ).toBeNull(); |
| 41 | }); | 41 | }); |
| 42 | }); | 42 | }); |
| 43 | |||
| 44 | describe("threadSyncLabel", () => { | ||
| 45 | it("uses the same loading and syncing language as mobile", () => { | ||
| 46 | expect(threadSyncLabel("loading")).toBe("Loading messages..."); | ||
| 47 | expect(threadSyncLabel("syncing")).toBe("Syncing messages..."); | ||
| 48 | }); | ||
| 49 | }); |
apps/web/src/timestampFormat.test.ts-39| ... | @@ -6,10 +6,7 @@ import { | ... | @@ -6,10 +6,7 @@ import { |
| 6 | formatExpiresInLabel, | 6 | formatExpiresInLabel, |
| 7 | formatRelativeTime, | 7 | formatRelativeTime, |
| 8 | formatRelativeTimeLabel, | 8 | formatRelativeTimeLabel, |
| 9 | formatRelativeTimeUntil, | ||
| 10 | formatRelativeTimeUntilLabel, | ||
| 11 | formatShortTimestamp, | 9 | formatShortTimestamp, |
| 12 | formatTimestamp, | ||
| 13 | getRelativeTimeState, | 10 | getRelativeTimeState, |
| 14 | getTimestampFormatOptions, | 11 | getTimestampFormatOptions, |
| 15 | resolveTimestampLocale, | 12 | resolveTimestampLocale, |
| ... | @@ -76,33 +73,6 @@ describe("resolveTimestampLocale", () => { | ... | @@ -76,33 +73,6 @@ describe("resolveTimestampLocale", () => { |
| 76 | }); | 73 | }); |
| 77 | }); | 74 | }); |
| 78 | 75 | ||
| 79 | describe("formatRelativeTimeUntilLabel", () => { | ||
| 80 | beforeEach(() => { | ||
| 81 | vi.useFakeTimers(); | ||
| 82 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); | ||
| 83 | }); | ||
| 84 | |||
| 85 | afterEach(() => { | ||
| 86 | vi.useRealTimers(); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("returns Expired when the instant is in the past", () => { | ||
| 90 | expect(formatRelativeTimeUntilLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("formats seconds remaining", () => { | ||
| 94 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:45.000Z")).toBe("45s left"); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("formats minutes remaining", () => { | ||
| 98 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:15:00.000Z")).toBe("15m left"); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("formats hours remaining", () => { | ||
| 102 | expect(formatRelativeTimeUntilLabel("2026-04-07T18:00:00.000Z")).toBe("6h left"); | ||
| 103 | }); | ||
| 104 | }); | ||
| 105 | |||
| 106 | describe("formatExpiresInLabel", () => { | 76 | describe("formatExpiresInLabel", () => { |
| 107 | beforeEach(() => { | 77 | beforeEach(() => { |
| 108 | vi.useFakeTimers(); | 78 | vi.useFakeTimers(); |
| ... | @@ -196,10 +166,6 @@ describe("formatDayAwareTimestamp", () => { | ... | @@ -196,10 +166,6 @@ describe("formatDayAwareTimestamp", () => { |
| 196 | }); | 166 | }); |
| 197 | 167 | ||
| 198 | describe("invalid timestamp inputs", () => { | 168 | describe("invalid timestamp inputs", () => { |
| 199 | it("returns an empty timestamp instead of throwing", () => { | ||
| 200 | expect(formatTimestamp("not-a-date", "12-hour")).toBe(""); | ||
| 201 | }); | ||
| 202 | |||
| 203 | it("returns an empty short timestamp instead of throwing", () => { | 169 | it("returns an empty short timestamp instead of throwing", () => { |
| 204 | expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); | 170 | expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); |
| 205 | }); | 171 | }); |
| ... | @@ -218,11 +184,6 @@ describe("invalid timestamp inputs", () => { | ... | @@ -218,11 +184,6 @@ describe("invalid timestamp inputs", () => { |
| 218 | expect(formatElapsedDurationLabel("not-a-date")).toBe(""); | 184 | expect(formatElapsedDurationLabel("not-a-date")).toBe(""); |
| 219 | }); | 185 | }); |
| 220 | 186 | ||
| 221 | it("returns an empty relative time until label instead of a NaN label", () => { | ||
| 222 | expect(formatRelativeTimeUntil("not-a-date")).toBeNull(); | ||
| 223 | expect(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); | ||
| 224 | }); | ||
| 225 | |||
| 226 | it("returns an empty expires-in label instead of a NaN label", () => { | 187 | it("returns an empty expires-in label instead of a NaN label", () => { |
| 227 | expect(formatExpiresInLabel("not-a-date")).toBe(""); | 188 | expect(formatExpiresInLabel("not-a-date")).toBe(""); |
| 228 | }); | 189 | }); |
apps/web/src/timestampFormat.ts-31| ... | @@ -78,12 +78,6 @@ export function parseTimestampDate(isoDate: string): Date | null { | ... | @@ -78,12 +78,6 @@ export function parseTimestampDate(isoDate: string): Date | null { |
| 78 | return Number.isNaN(date.getTime()) ? null : date; | 78 | return Number.isNaN(date.getTime()) ? null : date; |
| 79 | } | 79 | } |
| 80 | 80 | ||
| 81 | export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { | ||
| 82 | const date = parseTimestampDate(isoDate); | ||
| 83 | if (!date) return ""; | ||
| 84 | return getTimestampFormatter(timestampFormat, true).format(date); | ||
| 85 | } | ||
| 86 | |||
| 87 | // Deliberately not the host locale: the tooltip's ordinal suffix and | 81 | // Deliberately not the host locale: the tooltip's ordinal suffix and |
| 88 | // day-before-month order below are English, so a localized month alone would | 82 | // day-before-month order below are English, so a localized month alone would |
| 89 | // read "4th Juni 2026". Localizing the whole label is a separate change. | 83 | // read "4th Juni 2026". Localizing the whole label is a separate change. |
| ... | @@ -228,31 +222,6 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date | ... | @@ -228,31 +222,6 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date |
| 228 | return `${days}d`; | 222 | return `${days}d`; |
| 229 | } | 223 | } |
| 230 | 224 | ||
| 231 | /** | ||
| 232 | * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. | ||
| 233 | */ | ||
| 234 | export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { | ||
| 235 | const date = parseTimestampDate(isoDate); | ||
| 236 | if (!date) return null; | ||
| 237 | const diffMs = date.getTime() - Date.now(); | ||
| 238 | if (diffMs <= 0) return { value: "Expired", suffix: null }; | ||
| 239 | const seconds = Math.floor(diffMs / 1000); | ||
| 240 | if (seconds < 5) return { value: "Soon", suffix: null }; | ||
| 241 | if (seconds < 60) return { value: `${seconds}s`, suffix: "left" }; | ||
| 242 | const minutes = Math.floor(seconds / 60); | ||
| 243 | if (minutes < 60) return { value: `${minutes}m`, suffix: "left" }; | ||
| 244 | const hours = Math.floor(minutes / 60); | ||
| 245 | if (hours < 24) return { value: `${hours}h`, suffix: "left" }; | ||
| 246 | const days = Math.floor(hours / 24); | ||
| 247 | return { value: `${days}d`, suffix: "left" }; | ||
| 248 | } | ||
| 249 | |||
| 250 | export function formatRelativeTimeUntilLabel(isoDate: string): string { | ||
| 251 | const relative = formatRelativeTimeUntil(isoDate); | ||
| 252 | if (!relative) return ""; | ||
| 253 | return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; | ||
| 254 | } | ||
| 255 | |||
| 256 | /** | 225 | /** |
| 257 | * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. | 226 | * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. |
| 258 | * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. | 227 | * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. |
apps/web/src/versionSkew.test.ts-9| ... | @@ -8,7 +8,6 @@ vi.mock("./branding", () => branding); | ... | @@ -8,7 +8,6 @@ vi.mock("./branding", () => branding); |
| 8 | 8 | ||
| 9 | import { APP_VERSION } from "./branding"; | 9 | import { APP_VERSION } from "./branding"; |
| 10 | import { | 10 | import { |
| 11 | appendVersionMismatchHint, | ||
| 12 | buildVersionMismatchDismissalKey, | 11 | buildVersionMismatchDismissalKey, |
| 13 | dismissVersionMismatch, | 12 | dismissVersionMismatch, |
| 14 | isVersionMismatchDismissed, | 13 | isVersionMismatchDismissed, |
| ... | @@ -139,14 +138,6 @@ describe("versionSkew", () => { | ... | @@ -139,14 +138,6 @@ describe("versionSkew", () => { |
| 139 | ).toBe(false); | 138 | ).toBe(false); |
| 140 | }); | 139 | }); |
| 141 | 140 | ||
| 142 | it("appends a hint to connection errors when the server is behind", () => { | ||
| 143 | const mismatch = resolveVersionMismatch("0.0.33"); | ||
| 144 | |||
| 145 | expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe( | ||
| 146 | `Socket closed. Hint: ${MISMATCH_HINT}`, | ||
| 147 | ); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("reads desktop-managed update capabilities from config descriptors", () => { | 141 | it("reads desktop-managed update capabilities from config descriptors", () => { |
| 151 | expect( | 142 | expect( |
| 152 | resolveServerSelfUpdateCapability({ | 143 | resolveServerSelfUpdateCapability({ |
apps/web/src/versionSkew.ts-14| ... | @@ -159,17 +159,3 @@ export function dismissVersionMismatch(dismissalKey: string | null | undefined): | ... | @@ -159,17 +159,3 @@ export function dismissVersionMismatch(dismissalKey: string | null | undefined): |
| 159 | keys: [...document.keys, dismissalKey], | 159 | keys: [...document.keys, dismissalKey], |
| 160 | }); | 160 | }); |
| 161 | } | 161 | } |
| 162 | |||
| 163 | export function appendVersionMismatchHint( | ||
| 164 | message: string | null | undefined, | ||
| 165 | mismatch: VersionMismatch | null | undefined, | ||
| 166 | ): string | null { | ||
| 167 | const normalizedMessage = normalizeVersion(message); | ||
| 168 | if (!normalizedMessage) { | ||
| 169 | return mismatch?.hint ?? null; | ||
| 170 | } | ||
| 171 | if (!mismatch) { | ||
| 172 | return normalizedMessage; | ||
| 173 | } | ||
| 174 | return `${normalizedMessage} Hint: ${mismatch.hint}`; | ||
| 175 | } |