fix: localize usage and salary insights
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
const messages: Record<string, string> = {
|
||||
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({
|
||||
|
||||
@@ -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<Usage | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -29,7 +31,7 @@ export default function AiUsageCard() {
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
if (failed) return <Alert severity="warning">AI usage is temporarily unavailable.</Alert>;
|
||||
if (failed) return <Alert severity="warning">{t("settingsUsageUnavailable")}</Alert>;
|
||||
if (!usage) return <Skeleton variant="rounded" height={150} />;
|
||||
|
||||
const callsPercent = Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100);
|
||||
@@ -39,22 +41,22 @@ export default function AiUsageCard() {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, borderRadius: 4, border: "none" }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>AI usage</Typography>
|
||||
<Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{usage.plan} plan</Typography>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{t("settingsUsageTitle")}</Typography>
|
||||
<Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{t("settingsUsagePlan", { plan: usage.plan })}</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">{usage.currentMonth.calls.toLocaleString()} of {usage.monthlyCallLimit.toLocaleString()} generations this month</Typography>
|
||||
<Typography variant="body2">{t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(), limit: usage.monthlyCallLimit.toLocaleString() })}</Typography>
|
||||
<LinearProgress variant="determinate" value={callsPercent} aria-label="Monthly AI generations used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2">{usage.currentMonth.estimatedTokens.toLocaleString()} of {usage.monthlyTokenLimit.toLocaleString()} estimated tokens</Typography>
|
||||
<Typography variant="body2">{t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(), limit: usage.monthlyTokenLimit.toLocaleString() })}</Typography>
|
||||
<LinearProgress variant="determinate" value={tokensPercent} aria-label="Monthly AI tokens used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">{formatBytes(usage.storageUsedBytes)} of {formatBytes(usage.storageLimitBytes)} attachment storage</Typography>
|
||||
<Typography variant="body2">{t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes), limit: formatBytes(usage.storageLimitBytes) })}</Typography>
|
||||
<LinearProgress variant="determinate" value={storagePercent} aria-label="Attachment storage used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>AI limits reset at the start of each calendar month.</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsUsageReset")}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -533,16 +533,16 @@ export default function DashboardView() {
|
||||
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SectionCard>
|
||||
<Typography variant="h6">Salary insights</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>Comparable ranges from jobs in your tracker. Currencies and pay periods stay separate.</Typography>
|
||||
<Typography variant="h6">{t("dashboardSalaryInsights")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>{t("dashboardSalaryInsightsBody")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
|
||||
{overview!.salaryInsights!.map((item) => {
|
||||
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
|
||||
return (
|
||||
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="caption" color="text.secondary">{item.currency} · per {item.period} · {item.count} {item.count === 1 ? "job" : "jobs"}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })}</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Range {money.format(item.minimum)}–{money.format(item.maximum)}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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å.",
|
||||
|
||||
Reference in New Issue
Block a user