From 1ae99828193fd6f5b4f1d763a4feb6ac71cbd708 Mon Sep 17 00:00:00 2001 From: dmrock Date: Sat, 27 Dec 2025 10:21:37 +0100 Subject: [PATCH 1/7] feat: add currency selector for salary field --- convex/applications.ts | 18 +++ convex/schema.ts | 1 + src/app/applications/applications-client.tsx | 147 +++++++++++++++++-- 3 files changed, 152 insertions(+), 14 deletions(-) diff --git a/convex/applications.ts b/convex/applications.ts index 623f279..7b92a0e 100644 --- a/convex/applications.ts +++ b/convex/applications.ts @@ -12,6 +12,9 @@ const STAGES = [ ] as const; type Stage = (typeof STAGES)[number]; +export const CURRENCIES = ["USD", "EUR", "GBP"] as const; +type Currency = (typeof CURRENCIES)[number]; + export const listApplications = query({ args: {}, handler: async (ctx) => { @@ -40,6 +43,7 @@ export const createApplication = mutation({ company: v.string(), jobTitle: v.string(), salary: v.optional(v.number()), + currency: v.optional(v.string()), // USD, EUR, GBP stage: v.string(), // validate against STAGES at runtime date: v.string(), notes: v.string(), @@ -53,6 +57,11 @@ export const createApplication = mutation({ throw new Error("Invalid stage value"); } + // Validate currency if provided + if (args.currency && !CURRENCIES.includes(args.currency as Currency)) { + throw new Error("Invalid currency value"); + } + // Ensure user exists let user = await ctx.db .query("users") @@ -90,6 +99,8 @@ export const createApplication = mutation({ jobTitle: args.jobTitle, // Only include salary if provided ...(args.salary !== undefined ? { salary: args.salary } : {}), + // Only include currency if provided (defaults to USD on display) + ...(args.currency ? { currency: args.currency } : {}), stage: args.stage, date: args.date, notes: args.notes, @@ -106,6 +117,7 @@ export const updateApplication = mutation({ company: v.optional(v.string()), jobTitle: v.optional(v.string()), salary: v.optional(v.number()), + currency: v.optional(v.string()), // USD, EUR, GBP clearSalary: v.optional(v.boolean()), stage: v.optional(v.string()), date: v.optional(v.string()), @@ -129,11 +141,16 @@ export const updateApplication = mutation({ throw new Error("Invalid stage value"); } + if (args.currency && !CURRENCIES.includes(args.currency as Currency)) { + throw new Error("Invalid currency value"); + } + // Build a typed patch object without using `any` const patch: { company?: string; jobTitle?: string; salary?: number | undefined; + currency?: string; stage?: string; date?: string; notes?: string; @@ -142,6 +159,7 @@ export const updateApplication = mutation({ if (args.company !== undefined) patch.company = args.company; if (args.jobTitle !== undefined) patch.jobTitle = args.jobTitle; if (args.salary !== undefined) patch.salary = args.salary; + if (args.currency !== undefined) patch.currency = args.currency; if (args.stage !== undefined) patch.stage = args.stage; if (args.date !== undefined) patch.date = args.date; if (args.notes !== undefined) patch.notes = args.notes; diff --git a/convex/schema.ts b/convex/schema.ts index ebacb86..0a4c7a5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -16,6 +16,7 @@ export default defineSchema({ company: v.string(), // Company name jobTitle: v.string(), // Job title salary: v.optional(v.number()), // Salary (optional) + currency: v.optional(v.string()), // Currency code: USD, EUR, GBP (optional; defaults USD) stage: v.string(), // applied | cv_rejected | hr_call | interview | offer | rejected | ghosted date: v.string(), // Date of the stage notes: v.string(), // Optional notes diff --git a/src/app/applications/applications-client.tsx b/src/app/applications/applications-client.tsx index 1fd22ed..826896c 100644 --- a/src/app/applications/applications-client.tsx +++ b/src/app/applications/applications-client.tsx @@ -34,6 +34,101 @@ const STAGES = [ "ghosted", ] as const; type Stage = (typeof STAGES)[number]; + +const CURRENCIES = ["USD", "EUR", "GBP"] as const; +type Currency = (typeof CURRENCIES)[number]; +const CURRENCY_SYMBOLS: Record = { + USD: "$", + EUR: "€", + GBP: "£", +}; + +// Detect default currency based on user's timezone (most reliable) or locale +function getDefaultCurrency(): Currency { + if (typeof Intl === "undefined") return "USD"; + + // Try timezone first (more reliable than locale for physical location) + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Eurozone timezones + const eurozoneTimezones = [ + "Europe/Vienna", // Austria + "Europe/Brussels", // Belgium + "Europe/Nicosia", // Cyprus + "Europe/Tallinn", // Estonia + "Europe/Helsinki", // Finland + "Europe/Paris", // France + "Europe/Berlin", // Germany + "Europe/Athens", // Greece + "Europe/Dublin", // Ireland + "Europe/Rome", // Italy + "Europe/Riga", // Latvia + "Europe/Vilnius", // Lithuania + "Europe/Luxembourg", // Luxembourg + "Europe/Malta", // Malta + "Europe/Amsterdam", // Netherlands + "Europe/Lisbon", // Portugal + "Europe/Bratislava", // Slovakia + "Europe/Ljubljana", // Slovenia + "Europe/Madrid", // Spain + "Europe/Zagreb", // Croatia + "Atlantic/Canary", // Spain (Canary Islands) + ]; + if (eurozoneTimezones.includes(tz)) return "EUR"; + + // GBP timezones + if (tz === "Europe/London" || tz === "Europe/Belfast") return "GBP"; + } catch { + // Ignore timezone detection errors + } + + // Fallback to locale-based detection + if (typeof navigator === "undefined") return "USD"; + const locale = navigator.language || "en-US"; + + // Extract region code (e.g., "AT" from "de-AT" or "en-AT") + const regionMatch = locale.match(/-([A-Z]{2})$/i); + const region = regionMatch ? regionMatch[1].toUpperCase() : null; + + // Skip GB/UK region check here since timezone is more reliable + + // Eurozone countries (by region code) + const eurozoneRegions = [ + "AT", + "BE", + "CY", + "EE", + "FI", + "FR", + "DE", + "GR", + "IE", + "IT", + "LV", + "LT", + "LU", + "MT", + "NL", + "PT", + "SK", + "SI", + "ES", + "HR", + ]; + if (region && eurozoneRegions.includes(region)) return "EUR"; + if (region === "GB" || region === "UK") return "GBP"; + + // Fallback: check language prefix for locales without region + const lang = locale.split("-")[0].toLowerCase(); + if ( + ["de", "fr", "es", "it", "nl", "pt", "el", "fi", "sk", "sl", "et", "lv", "lt"].includes(lang) + ) { + return "EUR"; + } + + return "USD"; +} const STAGE_META: Record = { applied: { label: "Applied", @@ -117,6 +212,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency() as Currency, stage: "applied", date: nowLocalYMD(), notes: "", @@ -144,6 +240,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", @@ -209,6 +306,7 @@ export function ApplicationsClient() { stage: form.stage, date: form.date, notes: form.notes, + currency: form.currency, }; const salaryPatch = parsedSalary !== undefined @@ -253,6 +351,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", @@ -272,6 +371,7 @@ export function ApplicationsClient() { company: a.company, jobTitle: a.jobTitle, salary: a.salary != null ? String(a.salary) : "", + currency: (a.currency as Currency) || "USD", stage: (a.stage as Stage) ?? "applied", date: a.date, notes: a.notes, @@ -308,6 +408,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", @@ -340,19 +441,35 @@ export function ApplicationsClient() {
- { - const val = e.target.value; - setForm((s) => ({ ...s, salary: val })); - if (errors.salary) setErrors((prev) => ({ ...prev, salary: undefined })); - }} - placeholder="150000" - /> +
+ + { + const val = e.target.value; + setForm((s) => ({ ...s, salary: val })); + if (errors.salary) setErrors((prev) => ({ ...prev, salary: undefined })); + }} + placeholder="150000" + className="flex-1" + /> +
{errors.salary &&

{errors.salary}

}
@@ -508,7 +625,9 @@ export function ApplicationsClient() { {a.company} {a.jobTitle} - {a.salary != null ? `${Number(a.salary).toLocaleString()}` : "—"} + {a.salary != null + ? `${CURRENCY_SYMBOLS[(a.currency as Currency) || "USD"]}${Number(a.salary).toLocaleString()}` + : "—"}
From de1ef6506ee7159625c0d803fecb7ec8ad0f5f45 Mon Sep 17 00:00:00 2001 From: dmrock Date: Sat, 27 Dec 2025 10:31:24 +0100 Subject: [PATCH 2/7] feat: implement currency validation across application and schema --- convex/applications.ts | 20 +++++++++----------- convex/schema.ts | 9 ++++++++- src/app/applications/applications-client.tsx | 2 +- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/convex/applications.ts b/convex/applications.ts index 7b92a0e..ebff8eb 100644 --- a/convex/applications.ts +++ b/convex/applications.ts @@ -15,6 +15,13 @@ type Stage = (typeof STAGES)[number]; export const CURRENCIES = ["USD", "EUR", "GBP"] as const; type Currency = (typeof CURRENCIES)[number]; +// Validator for currency field - use in schema and mutations +export const currencyValidator = v.union( + v.literal("USD"), + v.literal("EUR"), + v.literal("GBP") +); + export const listApplications = query({ args: {}, handler: async (ctx) => { @@ -43,7 +50,7 @@ export const createApplication = mutation({ company: v.string(), jobTitle: v.string(), salary: v.optional(v.number()), - currency: v.optional(v.string()), // USD, EUR, GBP + currency: v.optional(currencyValidator), stage: v.string(), // validate against STAGES at runtime date: v.string(), notes: v.string(), @@ -57,11 +64,6 @@ export const createApplication = mutation({ throw new Error("Invalid stage value"); } - // Validate currency if provided - if (args.currency && !CURRENCIES.includes(args.currency as Currency)) { - throw new Error("Invalid currency value"); - } - // Ensure user exists let user = await ctx.db .query("users") @@ -117,7 +119,7 @@ export const updateApplication = mutation({ company: v.optional(v.string()), jobTitle: v.optional(v.string()), salary: v.optional(v.number()), - currency: v.optional(v.string()), // USD, EUR, GBP + currency: v.optional(currencyValidator), clearSalary: v.optional(v.boolean()), stage: v.optional(v.string()), date: v.optional(v.string()), @@ -141,10 +143,6 @@ export const updateApplication = mutation({ throw new Error("Invalid stage value"); } - if (args.currency && !CURRENCIES.includes(args.currency as Currency)) { - throw new Error("Invalid currency value"); - } - // Build a typed patch object without using `any` const patch: { company?: string; diff --git a/convex/schema.ts b/convex/schema.ts index 0a4c7a5..bc09b32 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,6 +1,13 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +// Currency validator - shared with applications.ts +const currencyValidator = v.union( + v.literal("USD"), + v.literal("EUR"), + v.literal("GBP") +); + export default defineSchema({ // Users (synced with Clerk) users: defineTable({ @@ -16,7 +23,7 @@ export default defineSchema({ company: v.string(), // Company name jobTitle: v.string(), // Job title salary: v.optional(v.number()), // Salary (optional) - currency: v.optional(v.string()), // Currency code: USD, EUR, GBP (optional; defaults USD) + currency: v.optional(currencyValidator), // Currency code: USD, EUR, GBP stage: v.string(), // applied | cv_rejected | hr_call | interview | offer | rejected | ghosted date: v.string(), // Date of the stage notes: v.string(), // Optional notes diff --git a/src/app/applications/applications-client.tsx b/src/app/applications/applications-client.tsx index 826896c..3121d1f 100644 --- a/src/app/applications/applications-client.tsx +++ b/src/app/applications/applications-client.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useMutation } from "convex/react"; import { api } from "../../../convex/_generated/api"; +import { CURRENCIES } from "../../../convex/applications"; import { Button } from "@/components/ui/button"; import { Pencil, Trash2, ChevronDown, ChevronUp, Star } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -35,7 +36,6 @@ const STAGES = [ ] as const; type Stage = (typeof STAGES)[number]; -const CURRENCIES = ["USD", "EUR", "GBP"] as const; type Currency = (typeof CURRENCIES)[number]; const CURRENCY_SYMBOLS: Record = { USD: "$", From 959747560f01abefe471a2cbd985d113451f70db Mon Sep 17 00:00:00 2001 From: dmrock Date: Sat, 27 Dec 2025 10:32:37 +0100 Subject: [PATCH 3/7] refactor: streamline currency validator definition in applications and schema --- convex/applications.ts | 6 +----- convex/schema.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/convex/applications.ts b/convex/applications.ts index ebff8eb..ac09751 100644 --- a/convex/applications.ts +++ b/convex/applications.ts @@ -16,11 +16,7 @@ export const CURRENCIES = ["USD", "EUR", "GBP"] as const; type Currency = (typeof CURRENCIES)[number]; // Validator for currency field - use in schema and mutations -export const currencyValidator = v.union( - v.literal("USD"), - v.literal("EUR"), - v.literal("GBP") -); +export const currencyValidator = v.union(v.literal("USD"), v.literal("EUR"), v.literal("GBP")); export const listApplications = query({ args: {}, diff --git a/convex/schema.ts b/convex/schema.ts index bc09b32..8f4b59a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -2,11 +2,7 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; // Currency validator - shared with applications.ts -const currencyValidator = v.union( - v.literal("USD"), - v.literal("EUR"), - v.literal("GBP") -); +const currencyValidator = v.union(v.literal("USD"), v.literal("EUR"), v.literal("GBP")); export default defineSchema({ // Users (synced with Clerk) From a0ab6f9cf49761419944d02eb1dd1ccf3c23eb09 Mon Sep 17 00:00:00 2001 From: dmrock Date: Sat, 27 Dec 2025 10:40:09 +0100 Subject: [PATCH 4/7] feat: update currency type in updateApplication mutation to use Currency type --- convex/applications.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/convex/applications.ts b/convex/applications.ts index ac09751..28a6f03 100644 --- a/convex/applications.ts +++ b/convex/applications.ts @@ -144,7 +144,7 @@ export const updateApplication = mutation({ company?: string; jobTitle?: string; salary?: number | undefined; - currency?: string; + currency?: Currency; stage?: string; date?: string; notes?: string; From cea522b86e3b62ff56b1b20df2d167aa1bb0183d Mon Sep 17 00:00:00 2001 From: dmrock Date: Sat, 27 Dec 2025 11:34:22 +0100 Subject: [PATCH 5/7] feat: add shared currency constants and validator for consistent usage across application --- convex/_generated/api.d.ts | 2 + convex/applications.ts | 6 +- convex/schema.ts | 4 +- convex/shared.ts | 12 ++ src/app/applications/applications-client.tsx | 153 +++++++++++-------- 5 files changed, 108 insertions(+), 69 deletions(-) create mode 100644 convex/shared.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 04c7d89..e7b1c2c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as applications from "../applications.js"; import type * as resumes from "../resumes.js"; +import type * as shared from "../shared.js"; import type * as users from "../users.js"; import type { @@ -21,6 +22,7 @@ import type { declare const fullApi: ApiFromModules<{ applications: typeof applications; resumes: typeof resumes; + shared: typeof shared; users: typeof users; }>; diff --git a/convex/applications.ts b/convex/applications.ts index 28a6f03..afcd3f0 100644 --- a/convex/applications.ts +++ b/convex/applications.ts @@ -1,5 +1,6 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { CURRENCIES, currencyValidator } from "./shared"; const STAGES = [ "applied", @@ -11,13 +12,8 @@ const STAGES = [ "ghosted", ] as const; type Stage = (typeof STAGES)[number]; - -export const CURRENCIES = ["USD", "EUR", "GBP"] as const; type Currency = (typeof CURRENCIES)[number]; -// Validator for currency field - use in schema and mutations -export const currencyValidator = v.union(v.literal("USD"), v.literal("EUR"), v.literal("GBP")); - export const listApplications = query({ args: {}, handler: async (ctx) => { diff --git a/convex/schema.ts b/convex/schema.ts index 8f4b59a..f6c6915 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,8 +1,6 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; - -// Currency validator - shared with applications.ts -const currencyValidator = v.union(v.literal("USD"), v.literal("EUR"), v.literal("GBP")); +import { currencyValidator } from "./shared"; export default defineSchema({ // Users (synced with Clerk) diff --git a/convex/shared.ts b/convex/shared.ts new file mode 100644 index 0000000..e90193e --- /dev/null +++ b/convex/shared.ts @@ -0,0 +1,12 @@ +/** + * Shared constants and validators used by both Convex backend and frontend. + * This file should NOT import any Convex server modules (query, mutation, etc.) + * to allow safe browser imports. + */ +import { v } from "convex/values"; + +export const CURRENCIES = ["USD", "EUR", "GBP"] as const; +export type Currency = (typeof CURRENCIES)[number]; + +// Validator for currency field - use in schema and mutations +export const currencyValidator = v.union(v.literal("USD"), v.literal("EUR"), v.literal("GBP")); diff --git a/src/app/applications/applications-client.tsx b/src/app/applications/applications-client.tsx index 3121d1f..4943396 100644 --- a/src/app/applications/applications-client.tsx +++ b/src/app/applications/applications-client.tsx @@ -4,7 +4,7 @@ import React, { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useMutation } from "convex/react"; import { api } from "../../../convex/_generated/api"; -import { CURRENCIES } from "../../../convex/applications"; +import { CURRENCIES } from "../../../convex/shared"; import { Button } from "@/components/ui/button"; import { Pencil, Trash2, ChevronDown, ChevronUp, Star } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -37,45 +37,92 @@ const STAGES = [ type Stage = (typeof STAGES)[number]; type Currency = (typeof CURRENCIES)[number]; + +// Default currency used as fallback when detection fails or currency is not set +const DEFAULT_CURRENCY: Currency = "USD"; + const CURRENCY_SYMBOLS: Record = { USD: "$", EUR: "€", GBP: "£", }; +// Eurozone IANA timezones for timezone-based currency detection +const EUROZONE_TIMEZONES = [ + "Europe/Vienna", // Austria + "Europe/Brussels", // Belgium + "Europe/Nicosia", // Cyprus + "Europe/Tallinn", // Estonia + "Europe/Helsinki", // Finland + "Europe/Paris", // France + "Europe/Berlin", // Germany + "Europe/Athens", // Greece + "Europe/Dublin", // Ireland + "Europe/Rome", // Italy + "Europe/Riga", // Latvia + "Europe/Vilnius", // Lithuania + "Europe/Luxembourg", // Luxembourg + "Europe/Malta", // Malta + "Europe/Amsterdam", // Netherlands + "Europe/Lisbon", // Portugal + "Europe/Bratislava", // Slovakia + "Europe/Ljubljana", // Slovenia + "Europe/Madrid", // Spain + "Europe/Zagreb", // Croatia + "Atlantic/Canary", // Spain (Canary Islands) +] as const; + +// ISO 3166-1 alpha-2 country codes for Eurozone members +const EUROZONE_REGIONS = [ + "AT", + "BE", + "CY", + "EE", + "FI", + "FR", + "DE", + "GR", + "IE", + "IT", + "LV", + "LT", + "LU", + "MT", + "NL", + "PT", + "SK", + "SI", + "ES", + "HR", +] as const; + +// ISO 639-1 language codes commonly used in Eurozone countries. +// Used as fallback when locale has no region suffix (e.g., "de" instead of "de-AT"). +const EUROZONE_LANGUAGE_CODES = [ + "de", // German + "fr", // French + "es", // Spanish + "it", // Italian + "nl", // Dutch + "pt", // Portuguese + "el", // Greek + "fi", // Finnish + "sk", // Slovak + "sl", // Slovenian + "et", // Estonian + "lv", // Latvian + "lt", // Lithuanian +] as const; + // Detect default currency based on user's timezone (most reliable) or locale -function getDefaultCurrency(): Currency { - if (typeof Intl === "undefined") return "USD"; +function detectDefaultCurrency(): Currency { + if (typeof Intl === "undefined") return DEFAULT_CURRENCY; // Try timezone first (more reliable than locale for physical location) try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - // Eurozone timezones - const eurozoneTimezones = [ - "Europe/Vienna", // Austria - "Europe/Brussels", // Belgium - "Europe/Nicosia", // Cyprus - "Europe/Tallinn", // Estonia - "Europe/Helsinki", // Finland - "Europe/Paris", // France - "Europe/Berlin", // Germany - "Europe/Athens", // Greece - "Europe/Dublin", // Ireland - "Europe/Rome", // Italy - "Europe/Riga", // Latvia - "Europe/Vilnius", // Lithuania - "Europe/Luxembourg", // Luxembourg - "Europe/Malta", // Malta - "Europe/Amsterdam", // Netherlands - "Europe/Lisbon", // Portugal - "Europe/Bratislava", // Slovakia - "Europe/Ljubljana", // Slovenia - "Europe/Madrid", // Spain - "Europe/Zagreb", // Croatia - "Atlantic/Canary", // Spain (Canary Islands) - ]; - if (eurozoneTimezones.includes(tz)) return "EUR"; + if (EUROZONE_TIMEZONES.includes(tz as (typeof EUROZONE_TIMEZONES)[number])) return "EUR"; // GBP timezones if (tz === "Europe/London" || tz === "Europe/Belfast") return "GBP"; @@ -84,51 +131,35 @@ function getDefaultCurrency(): Currency { } // Fallback to locale-based detection - if (typeof navigator === "undefined") return "USD"; + if (typeof navigator === "undefined") return DEFAULT_CURRENCY; const locale = navigator.language || "en-US"; // Extract region code (e.g., "AT" from "de-AT" or "en-AT") const regionMatch = locale.match(/-([A-Z]{2})$/i); const region = regionMatch ? regionMatch[1].toUpperCase() : null; - // Skip GB/UK region check here since timezone is more reliable - - // Eurozone countries (by region code) - const eurozoneRegions = [ - "AT", - "BE", - "CY", - "EE", - "FI", - "FR", - "DE", - "GR", - "IE", - "IT", - "LV", - "LT", - "LU", - "MT", - "NL", - "PT", - "SK", - "SI", - "ES", - "HR", - ]; - if (region && eurozoneRegions.includes(region)) return "EUR"; + if (region && EUROZONE_REGIONS.includes(region as (typeof EUROZONE_REGIONS)[number])) + return "EUR"; if (region === "GB" || region === "UK") return "GBP"; // Fallback: check language prefix for locales without region const lang = locale.split("-")[0].toLowerCase(); - if ( - ["de", "fr", "es", "it", "nl", "pt", "el", "fi", "sk", "sl", "et", "lv", "lt"].includes(lang) - ) { + if (EUROZONE_LANGUAGE_CODES.includes(lang as (typeof EUROZONE_LANGUAGE_CODES)[number])) { return "EUR"; } - return "USD"; + return DEFAULT_CURRENCY; } + +// Memoize default currency - computed once on first access +let _defaultCurrency: Currency | null = null; +function getDefaultCurrency(): Currency { + if (_defaultCurrency === null) { + _defaultCurrency = detectDefaultCurrency(); + } + return _defaultCurrency; +} + const STAGE_META: Record = { applied: { label: "Applied", @@ -371,7 +402,7 @@ export function ApplicationsClient() { company: a.company, jobTitle: a.jobTitle, salary: a.salary != null ? String(a.salary) : "", - currency: (a.currency as Currency) || "USD", + currency: (a.currency as Currency) || DEFAULT_CURRENCY, stage: (a.stage as Stage) ?? "applied", date: a.date, notes: a.notes, @@ -626,7 +657,7 @@ export function ApplicationsClient() { {a.jobTitle} {a.salary != null - ? `${CURRENCY_SYMBOLS[(a.currency as Currency) || "USD"]}${Number(a.salary).toLocaleString()}` + ? `${CURRENCY_SYMBOLS[(a.currency as Currency) || DEFAULT_CURRENCY]}${Number(a.salary).toLocaleString()}` : "—"} From e05df01ec33f90aeb7104c98a995e408abd0e428 Mon Sep 17 00:00:00 2001 From: dmrock Date: Sat, 27 Dec 2025 23:26:26 +0100 Subject: [PATCH 6/7] feat: update currency handling to use DEFAULT_CURRENCY and optimize Eurozone data structures --- convex/applications.ts | 2 +- src/app/applications/applications-client.tsx | 27 ++++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/convex/applications.ts b/convex/applications.ts index afcd3f0..c9a8ceb 100644 --- a/convex/applications.ts +++ b/convex/applications.ts @@ -93,7 +93,7 @@ export const createApplication = mutation({ jobTitle: args.jobTitle, // Only include salary if provided ...(args.salary !== undefined ? { salary: args.salary } : {}), - // Only include currency if provided (defaults to USD on display) + // Only include currency if provided (client-side defaults to DEFAULT_CURRENCY) ...(args.currency ? { currency: args.currency } : {}), stage: args.stage, date: args.date, diff --git a/src/app/applications/applications-client.tsx b/src/app/applications/applications-client.tsx index 4943396..3b32103 100644 --- a/src/app/applications/applications-client.tsx +++ b/src/app/applications/applications-client.tsx @@ -47,8 +47,8 @@ const CURRENCY_SYMBOLS: Record = { GBP: "£", }; -// Eurozone IANA timezones for timezone-based currency detection -const EUROZONE_TIMEZONES = [ +// Eurozone IANA timezones for timezone-based currency detection (Set for O(1) lookup) +const EUROZONE_TIMEZONES = new Set([ "Europe/Vienna", // Austria "Europe/Brussels", // Belgium "Europe/Nicosia", // Cyprus @@ -70,10 +70,10 @@ const EUROZONE_TIMEZONES = [ "Europe/Madrid", // Spain "Europe/Zagreb", // Croatia "Atlantic/Canary", // Spain (Canary Islands) -] as const; +]); -// ISO 3166-1 alpha-2 country codes for Eurozone members -const EUROZONE_REGIONS = [ +// ISO 3166-1 alpha-2 country codes for Eurozone members (Set for O(1) lookup) +const EUROZONE_REGIONS = new Set([ "AT", "BE", "CY", @@ -94,11 +94,11 @@ const EUROZONE_REGIONS = [ "SI", "ES", "HR", -] as const; +]); -// ISO 639-1 language codes commonly used in Eurozone countries. +// ISO 639-1 language codes commonly used in Eurozone countries (Set for O(1) lookup). // Used as fallback when locale has no region suffix (e.g., "de" instead of "de-AT"). -const EUROZONE_LANGUAGE_CODES = [ +const EUROZONE_LANGUAGE_CODES = new Set([ "de", // German "fr", // French "es", // Spanish @@ -112,7 +112,7 @@ const EUROZONE_LANGUAGE_CODES = [ "et", // Estonian "lv", // Latvian "lt", // Lithuanian -] as const; +]); // Detect default currency based on user's timezone (most reliable) or locale function detectDefaultCurrency(): Currency { @@ -122,7 +122,7 @@ function detectDefaultCurrency(): Currency { try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (EUROZONE_TIMEZONES.includes(tz as (typeof EUROZONE_TIMEZONES)[number])) return "EUR"; + if (EUROZONE_TIMEZONES.has(tz)) return "EUR"; // GBP timezones if (tz === "Europe/London" || tz === "Europe/Belfast") return "GBP"; @@ -138,13 +138,12 @@ function detectDefaultCurrency(): Currency { const regionMatch = locale.match(/-([A-Z]{2})$/i); const region = regionMatch ? regionMatch[1].toUpperCase() : null; - if (region && EUROZONE_REGIONS.includes(region as (typeof EUROZONE_REGIONS)[number])) - return "EUR"; + if (region && EUROZONE_REGIONS.has(region)) return "EUR"; if (region === "GB" || region === "UK") return "GBP"; // Fallback: check language prefix for locales without region const lang = locale.split("-")[0].toLowerCase(); - if (EUROZONE_LANGUAGE_CODES.includes(lang as (typeof EUROZONE_LANGUAGE_CODES)[number])) { + if (EUROZONE_LANGUAGE_CODES.has(lang)) { return "EUR"; } @@ -243,7 +242,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", - currency: getDefaultCurrency() as Currency, + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", From 443b6668d5180e400a183edf446436515f2fc715 Mon Sep 17 00:00:00 2001 From: dmrock Date: Sat, 27 Dec 2025 23:30:16 +0100 Subject: [PATCH 7/7] feat: add aria-label for currency select in applications client --- src/app/applications/applications-client.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/applications/applications-client.tsx b/src/app/applications/applications-client.tsx index 3b32103..0625a17 100644 --- a/src/app/applications/applications-client.tsx +++ b/src/app/applications/applications-client.tsx @@ -473,6 +473,7 @@ export function ApplicationsClient() {