From 6ed56fd493ebc5a1b51aad25afdcc1f86584fde7 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 31 Jul 2026 00:17:33 +0200 Subject: [PATCH] fix: localize usage and salary insights --- Models/JobApplication.cs | 4 ++-- job-tracker-ui/src/ai-usage-card.test.tsx | 12 ++++++++++ job-tracker-ui/src/components/AiUsageCard.tsx | 16 ++++++++------ .../src/components/DashboardView.tsx | 8 +++---- job-tracker-ui/src/i18n/translations.ts | 22 +++++++++++++++++++ 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index 15caaff..a2747c9 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -10,8 +10,8 @@ public class JobApplication public int CompanyId { get; set; } public Company Company { get; set; } = null!; - // The opportunity this application is for. Nullable and unused for now: Phase 0 added the - // Job entity additively and JobApplication still owns the opportunity columns below. + // The opportunity this application is for. Nullable for legacy rows; new creates and edits + // keep Job synchronized while JobApplication remains the current read model. // See Models/Job.cs and docs/decisions/ADR-002-job-application-model.md. public int? JobId { get; set; } public Job? Job { get; set; } diff --git a/job-tracker-ui/src/ai-usage-card.test.tsx b/job-tracker-ui/src/ai-usage-card.test.tsx index 4e079cf..02c9a3d 100644 --- a/job-tracker-ui/src/ai-usage-card.test.tsx +++ b/job-tracker-ui/src/ai-usage-card.test.tsx @@ -5,6 +5,18 @@ import AiUsageCard from "./components/AiUsageCard"; import { api } from "./api"; jest.mock("./api", () => ({ api: { get: jest.fn() } })); +jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ t: (key: string, params?: Record) => { + const messages: Record = { + settingsUsageTitle: "Account usage", + settingsUsageUnavailable: "Account usage is temporarily unavailable.", + settingsUsagePlan: "{plan} plan", + settingsUsageGenerations: "{used} of {limit} generations this month", + settingsUsageTokens: "{used} of {limit} estimated tokens", + settingsUsageStorage: "{used} of {limit} attachment storage", + settingsUsageReset: "AI limits reset at the start of each calendar month.", + }; + return Object.entries(params ?? {}).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), messages[key] ?? key); +} }) })); test("shows monthly AI call and token limits", async () => { (api.get as jest.Mock).mockResolvedValue({ diff --git a/job-tracker-ui/src/components/AiUsageCard.tsx b/job-tracker-ui/src/components/AiUsageCard.tsx index da529aa..fb96688 100644 --- a/job-tracker-ui/src/components/AiUsageCard.tsx +++ b/job-tracker-ui/src/components/AiUsageCard.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { Alert, Box, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material"; import { api } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; type Usage = { currentMonth: { calls: number; estimatedTokens: number }; @@ -18,6 +19,7 @@ function formatBytes(bytes: number) { } export default function AiUsageCard() { + const { t } = useI18n(); const [usage, setUsage] = useState(null); const [failed, setFailed] = useState(false); @@ -29,7 +31,7 @@ export default function AiUsageCard() { return () => { active = false; }; }, []); - if (failed) return AI usage is temporarily unavailable.; + if (failed) return {t("settingsUsageUnavailable")}; if (!usage) return ; const callsPercent = Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100); @@ -39,22 +41,22 @@ export default function AiUsageCard() { return ( - AI usage - {usage.plan} plan + {t("settingsUsageTitle")} + {t("settingsUsagePlan", { plan: usage.plan })} - {usage.currentMonth.calls.toLocaleString()} of {usage.monthlyCallLimit.toLocaleString()} generations this month + {t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(), limit: usage.monthlyCallLimit.toLocaleString() })} - {usage.currentMonth.estimatedTokens.toLocaleString()} of {usage.monthlyTokenLimit.toLocaleString()} estimated tokens + {t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(), limit: usage.monthlyTokenLimit.toLocaleString() })} - {formatBytes(usage.storageUsedBytes)} of {formatBytes(usage.storageLimitBytes)} attachment storage + {t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes), limit: formatBytes(usage.storageLimitBytes) })} - AI limits reset at the start of each calendar month. + {t("settingsUsageReset")} ); } diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 4546107..e10600d 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -533,16 +533,16 @@ export default function DashboardView() { {(overview?.salaryInsights?.length ?? 0) > 0 ? ( - Salary insights - Comparable ranges from jobs in your tracker. Currencies and pay periods stay separate. + {t("dashboardSalaryInsights")} + {t("dashboardSalaryInsightsBody")} {overview!.salaryInsights!.map((item) => { const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 }); return ( - {item.currency} · per {item.period} · {item.count} {item.count === 1 ? "job" : "jobs"} + {t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })} {money.format(item.averageMidpoint)} - Range {money.format(item.minimum)}–{money.format(item.maximum)} + {t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })} ); })} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 5f11d18..e7c03b0 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -137,6 +137,13 @@ export const translations = { pipelineGroupActive: "Active", pipelineGroupClosed: "Closed", settingsTitle: "Settings", + settingsUsageTitle: "Account usage", + settingsUsageUnavailable: "Account usage is temporarily unavailable.", + settingsUsagePlan: "{plan} plan", + settingsUsageGenerations: "{used} of {limit} generations this month", + settingsUsageTokens: "{used} of {limit} estimated tokens", + settingsUsageStorage: "{used} of {limit} attachment storage", + settingsUsageReset: "AI limits reset at the start of each calendar month.", settingsSubtitle: "Preferences and admin tools.", settingsTabGeneral: "General", settingsTabFollowUps: "Follow-ups", @@ -431,6 +438,10 @@ export const translations = { dashboardTimeInStageTitle: "Median time in stage", dashboardTimeInStageValue: "{days}d · {count} active", dashboardResponseSources: "Response sources", + dashboardSalaryInsights: "Salary insights", + dashboardSalaryInsightsBody: "Comparable ranges from jobs in your tracker. Currencies and pay periods stay separate.", + dashboardSalaryGroup: "{currency} · per {period} · {count} jobs", + dashboardSalaryRange: "Range {minimum}–{maximum}", dashboardTopCompaniesByActivity: "Top companies by activity", dashboardTopSkills: "Top skills", dashboardNoTagsYet: "No tags yet.", @@ -1214,6 +1225,13 @@ export const translations = { pipelineGroupActive: "Aktive", pipelineGroupClosed: "Avsluttet", settingsTitle: "Innstillinger", + settingsUsageTitle: "Kontobruk", + settingsUsageUnavailable: "Kontobruk er midlertidig utilgjengelig.", + settingsUsagePlan: "{plan}-abonnement", + settingsUsageGenerations: "{used} av {limit} genereringer denne måneden", + settingsUsageTokens: "{used} av {limit} estimerte tokener", + settingsUsageStorage: "{used} av {limit} vedleggslagring", + settingsUsageReset: "KI-grensene nullstilles ved starten av hver kalendermåned.", settingsSubtitle: "Preferanser og adminverktøy.", settingsTabGeneral: "Generelt", settingsTabFollowUps: "Oppfølging", @@ -1508,6 +1526,10 @@ export const translations = { dashboardTimeInStageTitle: "Median tid i fase", dashboardTimeInStageValue: "{days}d · {count} aktive", dashboardResponseSources: "Svar etter kilde", + dashboardSalaryInsights: "Lønnsinnsikt", + dashboardSalaryInsightsBody: "Sammenlignbare intervaller fra jobber i oversikten din. Valutaer og lønnsperioder holdes adskilt.", + dashboardSalaryGroup: "{currency} · per {period} · {count} jobber", + dashboardSalaryRange: "Intervall {minimum}–{maximum}", dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet", dashboardTopSkills: "Topp ferdigheter", dashboardNoTagsYet: "Ingen tagger ennå.",