From 27548ada00cf115b37db9e4341edfc6885214a05 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:25:40 -0700 Subject: [PATCH 01/31] web: the date guard, red first (G1 walk note N15) A Vitest leg over every .ts source under web/apps/ (tests excluded) that fails on a native date, datetime-local, month or time input, and on toLocaleDateString(, toLocaleTimeString( or Intl.DateTimeFormat( outside lib/dates.ts, naming each file and line. It fails on today's code: the native date inputs are still in the pages and dates are formatted on their own all over the app. Signed-off-by: colton --- web/apps/desk/src/lib/dates.guard.test.ts | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 web/apps/desk/src/lib/dates.guard.test.ts diff --git a/web/apps/desk/src/lib/dates.guard.test.ts b/web/apps/desk/src/lib/dates.guard.test.ts new file mode 100644 index 000000000..6b6685923 --- /dev/null +++ b/web/apps/desk/src/lib/dates.guard.test.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The date guard (G1 walk note N15). + * + * The app has one date module, `web/apps/desk/src/lib/dates.ts`, and one date + * field, ``. This guard walks every `.ts` source under + * `web/apps/` (tests excluded) and fails on: + * + * - a native `date`, `datetime-local`, `month` or `time` input (the + * browser's white system calendar and its "mm/dd/yyyy" placeholder), + * - a `toLocaleDateString(`, `toLocaleTimeString(` or + * `Intl.DateTimeFormat(` call outside `lib/dates.ts`, so no page formats + * a date on its own in the browser's locale instead of the dealer's. + * + * Every finding names its file and line. + */ +import { describe, it, expect } from 'vitest' +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' + +const APPS_ROOT = join(__dirname, '..', '..', '..') + +// The one module allowed to call the date formatters. +const EXEMPT = join('desk', 'src', 'lib', 'dates.ts') + +function* sources(dir: string): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue + yield* sources(path) + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { + yield path + } + } +} + +const RULES: Array<{ kind: string; pattern: RegExp }> = [ + { + kind: 'native date/time input', + pattern: /type\s*=\s*['"](?:date|datetime-local|month|time)['"]/, + }, + { kind: 'toLocaleDateString(', pattern: /\.toLocaleDateString\s*\(/ }, + { kind: 'toLocaleTimeString(', pattern: /\.toLocaleTimeString\s*\(/ }, + { kind: 'Intl.DateTimeFormat(', pattern: /Intl\.DateTimeFormat\s*\(/ }, +] + +describe('date guard', () => { + it('no native date inputs and no own date formatting outside lib/dates.ts', () => { + const findings: string[] = [] + for (const file of sources(APPS_ROOT)) { + const rel = relative(APPS_ROOT, file).split(sep).join('/') + if (rel === EXEMPT.split(sep).join('/')) continue + const lines = readFileSync(file, 'utf8').split('\n') + lines.forEach((line, i) => { + for (const rule of RULES) { + if (rule.pattern.test(line)) { + findings.push(`${rel}:${i + 1}: ${rule.kind}: ${line.trim()}`) + } + } + }) + } + expect(findings).toEqual([]) + }) +}) From 399e9826bbfddb938a105f4e1353959727162182 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:58:47 -0700 Subject: [PATCH 02/31] core: the dealer's locale on the profile, read alone and on the portal config (G1 walk note N15) The dealer profile carries locale (a BCP 47 tag the write validates, refused in words otherwise; a write without the field keeps the stored one), added by migration 119 with the en-US default; the demo seed sets en-CA and lifts an already seeded demo profile still on the default, keeping an operator's own tag. GET /api/v1/dealer-profile/locale answers the locale alone to every signed in desk role (the profile read's own guard is unchanged; a branch bound key's class is stated in the allowlist), and the portal's public config and login response carry the same locale for the portal's pages. The contract fragment, the assembled openapi.yaml, the census, the generated TypeScript schema, the portal goldens (the locale line only) and a CONTRACT-CHANGES row naming item 130 and N15 ride along, with wire legs on the module and the composed server and the migration's test. Signed-off-by: colton --- core/api/ROUTES.txt | 1 + core/api/fragments/dealerprofile.yaml | 45 +++++- core/api/fragments/portal.yaml | 7 +- core/api/openapi.yaml | 49 +++++- core/go.mod | 2 +- core/internal/app/seed/profile.go | 12 +- core/internal/app/seed/seed_test.go | 41 +++-- core/internal/app/serve/wire_branch_wall.go | 9 +- .../app/serve/wire_branch_wall_test.go | 37 +++++ .../testdata/goldens/portal.json | 2 + .../testdata/goldens/portal_billing.json | 1 + .../testdata/goldens/portal_catalog.json | 1 + .../testdata/goldens/portal_logout.json | 1 + .../testdata/goldens/portal_orders.json | 1 + .../testdata/goldens/portal_projects.json | 1 + .../testdata/goldens/portal_quotes.json | 1 + .../testdata/goldens/portal_team.json | 1 + core/internal/dealerprofile/handler.go | 48 ++++-- .../dealerprofile/migration119_test.go | 143 ++++++++++++++++++ core/internal/dealerprofile/model.go | 32 +++- core/internal/dealerprofile/repository.go | 8 +- core/internal/dealerprofile/service.go | 15 +- core/internal/dealerprofile/wire_test.go | 56 ++++++- core/internal/portal/model.go | 4 +- core/internal/portal/repository.go | 9 +- core/migrations/119_dealer_profile_locale.sql | 18 +++ .../down/119_dealer_profile_locale_down.sql | 9 ++ core/pkg/middleware/keybranch_allow.go | 1 + docs/refactor/CONTRACT-CHANGES.md | 1 + web/packages/api-client/src/schema.d.ts | 58 ++++++- 30 files changed, 561 insertions(+), 53 deletions(-) create mode 100644 core/internal/dealerprofile/migration119_test.go create mode 100644 core/migrations/119_dealer_profile_locale.sql create mode 100644 core/migrations/down/119_dealer_profile_locale_down.sql diff --git a/core/api/ROUTES.txt b/core/api/ROUTES.txt index 64f8560ff..0ab0e48d6 100644 --- a/core/api/ROUTES.txt +++ b/core/api/ROUTES.txt @@ -167,6 +167,7 @@ GET /api/v1/dashboard/summary internal/dashboard h.HandleSummary GET /api/v1/dashboard/top-customers internal/dashboard h.HandleTopCustomers GET /api/v1/dealer-profile internal/dealerprofile guarded(g.Read, h.Get) PUT /api/v1/dealer-profile internal/dealerprofile guarded(g.Write, h.Put) +GET /api/v1/dealer-profile/locale internal/dealerprofile guarded(g.Locale, h.GetLocale) GET /api/v1/dealer-profile/logo internal/dealerprofile guarded(g.Read, h.GetLogo) PUT /api/v1/dealer-profile/logo internal/dealerprofile guarded(g.Write, h.PutLogo) POST /api/v1/delivery/deliveries internal/delivery h.HandleAssignOrder diff --git a/core/api/fragments/dealerprofile.yaml b/core/api/fragments/dealerprofile.yaml index 26c5fa3b9..705f777c6 100644 --- a/core/api/fragments/dealerprofile.yaml +++ b/core/api/fragments/dealerprofile.yaml @@ -41,7 +41,8 @@ paths: tags: [dealer-profile] summary: Replace the dealer profile description: >- - Replaces the writable fields (the logo has its own route). Role gated + Replaces the writable fields (the logo has its own route; a locale left + out keeps the stored one). Role gated admin and owner. The revision precondition applies: If-Match or the body revision, 428 without either, 409 stale_revision when stale. A branch bound machine key is refused. The write is audited with the @@ -80,6 +81,32 @@ paths: $ref: "#/components/responses/WirePreconditionRequired" "500": $ref: "#/components/responses/WireInternalError" + /api/v1/dealer-profile/locale: + get: + operationId: dealerProfileLocaleGet + tags: [dealer-profile] + summary: Read the dealer's locale alone + description: >- + The locale alone, {"locale": "en-CA"}: every signed in desk person may + call it (the profile read itself is held to the roles that print), so + every page can format its dates for the dealer's locale. + security: + - BearerJWT: [] + responses: + "200": + description: The dealer's locale. + content: + application/json: + schema: + $ref: "#/components/schemas/DealerProfileLocale" + "400": + $ref: "#/components/responses/WireBadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/WireInternalError" /api/v1/dealer-profile/logo: get: operationId: dealerProfileLogoGet @@ -169,9 +196,16 @@ paths: $ref: "#/components/responses/WireInternalError" components: schemas: + DealerProfileLocale: + type: object + required: [locale] + properties: + locale: + type: string + description: The dealer's locale (a BCP 47 tag). DealerProfile: type: object - required: [legal_name, trading_name, phone, email, website, remit_to, logo_url, revision, updated_at] + required: [legal_name, trading_name, phone, email, website, remit_to, locale, logo_url, revision, updated_at] properties: legal_name: type: string @@ -186,6 +220,9 @@ components: remit_to: type: string description: The address payments are sent to; invoices and statements print it. + locale: + type: string + description: The dealer's locale (a BCP 47 tag, default en-US); every date the app shows is formatted for it. logo_url: type: [string, "null"] description: /api/v1/dealer-profile/logo when a logo is set, otherwise null. @@ -217,6 +254,10 @@ components: remit_to: type: string maxLength: 1000 + locale: + type: string + maxLength: 35 + description: A BCP 47 tag (en-US, en-CA), refused in words otherwise. Leave it out to keep the stored one. revision: type: integer format: int64 diff --git a/core/api/fragments/portal.yaml b/core/api/fragments/portal.yaml index 609a4112e..57eef3b8c 100644 --- a/core/api/fragments/portal.yaml +++ b/core/api/fragments/portal.yaml @@ -70,7 +70,7 @@ paths: operationId: portalGetConfig tags: [portal] summary: The dealer branding config - description: Public. The one row of white-label branding the login page renders before any session exists. + description: Public. The one row of white-label branding the login page renders before any session exists, with the dealer's locale for the portal's dates. security: [] responses: "200": @@ -1227,7 +1227,7 @@ components: format: date-time PortalConfig: type: object - required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, created_at, updated_at] + required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, locale, created_at, updated_at] description: The white-label branding row. properties: id: @@ -1243,6 +1243,9 @@ components: type: string support_phone: type: string + locale: + type: string + description: The dealer profile's locale (a BCP 47 tag); the portal's pages format their dates for it. created_at: type: string format: date-time diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index d889a3c88..1de9c0786 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -3244,7 +3244,7 @@ paths: tags: [dealer-profile] summary: Replace the dealer profile description: >- - Replaces the writable fields (the logo has its own route). Role gated admin and owner. The revision precondition applies: If-Match or the body revision, 428 without either, 409 stale_revision when stale. A branch bound machine key is refused. The write is audited with the before and after in one transaction. + Replaces the writable fields (the logo has its own route; a locale left out keeps the stored one). Role gated admin and owner. The revision precondition applies: If-Match or the body revision, 428 without either, 409 stale_revision when stale. A branch bound machine key is refused. The write is audited with the before and after in one transaction. security: - BearerJWT: [] parameters: @@ -3279,6 +3279,30 @@ paths: $ref: "#/components/responses/WirePreconditionRequired" "500": $ref: "#/components/responses/WireInternalError" + /api/v1/dealer-profile/locale: + get: + operationId: dealerProfileLocaleGet + tags: [dealer-profile] + summary: Read the dealer's locale alone + description: >- + The locale alone, {"locale": "en-CA"}: every signed in desk person may call it (the profile read itself is held to the roles that print), so every page can format its dates for the dealer's locale. + security: + - BearerJWT: [] + responses: + "200": + description: The dealer's locale. + content: + application/json: + schema: + $ref: "#/components/schemas/DealerProfileLocale" + "400": + $ref: "#/components/responses/WireBadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/WireInternalError" /api/v1/dealer-profile/logo: get: operationId: dealerProfileLogoGet @@ -9621,7 +9645,7 @@ paths: operationId: portalGetConfig tags: [portal] summary: The dealer branding config - description: Public. The one row of white-label branding the login page renders before any session exists. + description: Public. The one row of white-label branding the login page renders before any session exists, with the dealer's locale for the portal's dates. security: [] responses: "200": @@ -18295,9 +18319,16 @@ components: type: integer format: int64 description: Cents. + DealerProfileLocale: + type: object + required: [locale] + properties: + locale: + type: string + description: The dealer's locale (a BCP 47 tag). DealerProfile: type: object - required: [legal_name, trading_name, phone, email, website, remit_to, logo_url, revision, updated_at] + required: [legal_name, trading_name, phone, email, website, remit_to, locale, logo_url, revision, updated_at] properties: legal_name: type: string @@ -18312,6 +18343,9 @@ components: remit_to: type: string description: The address payments are sent to; invoices and statements print it. + locale: + type: string + description: The dealer's locale (a BCP 47 tag, default en-US); every date the app shows is formatted for it. logo_url: type: [string, "null"] description: /api/v1/dealer-profile/logo when a logo is set, otherwise null. @@ -18343,6 +18377,10 @@ components: remit_to: type: string maxLength: 1000 + locale: + type: string + maxLength: 35 + description: A BCP 47 tag (en-US, en-CA), refused in words otherwise. Leave it out to keep the stored one. revision: type: integer format: int64 @@ -23647,7 +23685,7 @@ components: format: date-time PortalConfig: type: object - required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, created_at, updated_at] + required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, locale, created_at, updated_at] description: The white-label branding row. properties: id: @@ -23663,6 +23701,9 @@ components: type: string support_phone: type: string + locale: + type: string + description: The dealer profile's locale (a BCP 47 tag); the portal's pages format their dates for it. created_at: type: string format: date-time diff --git a/core/go.mod b/core/go.mod index ac977ff37..8047e069c 100644 --- a/core/go.mod +++ b/core/go.mod @@ -15,6 +15,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/xuri/excelize/v2 v2.11.0 golang.org/x/crypto v0.57.0 + golang.org/x/text v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -51,7 +52,6 @@ require ( golang.org/x/net v0.60.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/core/internal/app/seed/profile.go b/core/internal/app/seed/profile.go index 094b99fa4..9d16e66e3 100644 --- a/core/internal/app/seed/profile.go +++ b/core/internal/app/seed/profile.go @@ -38,7 +38,10 @@ var demoLogoPNG = []byte{ // seedDealerProfile sets the demo's invented dealer profile (migration 115's // single row) and logo. It writes only a profile that is still blank, so a -// profile an operator has filled in is never overwritten by a re-seed. +// profile an operator has filled in is never overwritten by a re-seed. The +// locale is en-CA (the demo dealer is in British Columbia); a profile already +// seeded by the demo that still sits on the migration's en-US default is +// lifted to en-CA, while any other tag (an operator's own choice) is kept. func seedDealerProfile(db *sql.DB) { res, err := db.Exec(` UPDATE dealer_profile @@ -48,6 +51,7 @@ func seedDealerProfile(db *sql.DB) { email = 'accounts@gablebuildingsupply.example', website = 'https://gablebuildingsupply.example', remit_to = 'Gable Building Supply, Accounts Receivable' || E'\n' || 'PO Box 4100, Kelowna BC V1Y 7R7', + locale = 'en-CA', logo = $1, logo_content_type = 'image/png', revision = revision + 1, updated_at = NOW() WHERE id = 1 AND legal_name = '' AND trading_name = '' AND logo IS NULL`, demoLogo()) @@ -57,5 +61,11 @@ func seedDealerProfile(db *sql.DB) { } if n, _ := res.RowsAffected(); n > 0 { fmt.Println("Seed: Dealer Profile (invented) and logo") + return + } + if _, err := db.Exec(` + UPDATE dealer_profile SET locale = 'en-CA', updated_at = NOW() + WHERE id = 1 AND locale = 'en-US' AND trading_name = 'Gable Building Supply'`); err != nil { + log.Printf("seed: dealer profile locale: %v", err) } } diff --git a/core/internal/app/seed/seed_test.go b/core/internal/app/seed/seed_test.go index f91129aab..154165016 100644 --- a/core/internal/app/seed/seed_test.go +++ b/core/internal/app/seed/seed_test.go @@ -426,7 +426,10 @@ func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) { } // The demo seed sets an invented dealer profile and logo, once: a profile the -// operator has filled in is not overwritten by a re-seed. +// operator has filled in is not overwritten by a re-seed. The locale is +// en-CA (the demo dealer is in British Columbia): set on the blank write, and +// lifted from the migration's en-US default on a profile the demo already +// seeded; an operator's own tag is kept. // // DB-gated: it runs the seeder's actual statement against the single row. func TestDealerProfileSeed_SetsOnlyABlankProfile(t *testing.T) { @@ -434,28 +437,29 @@ func TestDealerProfileSeed_SetsOnlyABlankProfile(t *testing.T) { ctx := context.Background() type snap struct { legal, trading, phone, email, website, remit string + locale string logo []byte ctype *string revision int64 } var orig snap - if err := db.Pool.QueryRow(ctx, `SELECT legal_name, trading_name, phone, email, website, remit_to, logo, logo_content_type, revision FROM dealer_profile WHERE id = 1`). - Scan(&orig.legal, &orig.trading, &orig.phone, &orig.email, &orig.website, &orig.remit, &orig.logo, &orig.ctype, &orig.revision); err != nil { + if err := db.Pool.QueryRow(ctx, `SELECT legal_name, trading_name, phone, email, website, remit_to, locale, logo, logo_content_type, revision FROM dealer_profile WHERE id = 1`). + Scan(&orig.legal, &orig.trading, &orig.phone, &orig.email, &orig.website, &orig.remit, &orig.locale, &orig.logo, &orig.ctype, &orig.revision); err != nil { t.Fatal(err) } t.Cleanup(func() { - _, _ = db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name=$1, trading_name=$2, phone=$3, email=$4, website=$5, remit_to=$6, logo=$7, logo_content_type=$8, revision=$9 WHERE id = 1`, - orig.legal, orig.trading, orig.phone, orig.email, orig.website, orig.remit, orig.logo, orig.ctype, orig.revision) + _, _ = db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name=$1, trading_name=$2, phone=$3, email=$4, website=$5, remit_to=$6, locale=$7, logo=$8, logo_content_type=$9, revision=$10 WHERE id = 1`, + orig.legal, orig.trading, orig.phone, orig.email, orig.website, orig.remit, orig.locale, orig.logo, orig.ctype, orig.revision) }) blank := func() { t.Helper() - if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name='', trading_name='', phone='', email='', website='', remit_to='', logo=NULL, logo_content_type=NULL WHERE id = 1`); err != nil { + if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name='', trading_name='', phone='', email='', website='', remit_to='', locale='en-US', logo=NULL, logo_content_type=NULL WHERE id = 1`); err != nil { t.Fatal(err) } } - read := func() (legal, remit string, hasLogo bool) { + read := func() (legal, remit, locale string, hasLogo bool) { t.Helper() - if err := db.Pool.QueryRow(ctx, `SELECT legal_name, remit_to, logo IS NOT NULL FROM dealer_profile WHERE id = 1`).Scan(&legal, &remit, &hasLogo); err != nil { + if err := db.Pool.QueryRow(ctx, `SELECT legal_name, remit_to, locale, logo IS NOT NULL FROM dealer_profile WHERE id = 1`).Scan(&legal, &remit, &locale, &hasLogo); err != nil { t.Fatal(err) } return @@ -472,16 +476,31 @@ func TestDealerProfileSeed_SetsOnlyABlankProfile(t *testing.T) { blank() seedDealerProfile(sqlDB) - legal, remit, hasLogo := read() + legal, remit, locale, hasLogo := read() if legal != "Gable Building Supply Ltd" || !strings.Contains(remit, "Accounts Receivable") || !hasLogo { t.Fatalf("a blank profile was not seeded: %q %q logo=%v", legal, remit, hasLogo) } - if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name = 'Edited By The Operator Ltd' WHERE id = 1`); err != nil { + if locale != "en-CA" { + t.Errorf("the seeded locale = %q, want en-CA", locale) + } + if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name = 'Edited By The Operator Ltd', locale = 'fr-CA' WHERE id = 1`); err != nil { t.Fatal(err) } seedDealerProfile(sqlDB) - if legal, _, _ := read(); legal != "Edited By The Operator Ltd" { + if legal, _, locale, _ := read(); legal != "Edited By The Operator Ltd" { t.Errorf("a re-seed overwrote the operator's profile: %q", legal) + } else if locale != "fr-CA" { + t.Errorf("a re-seed overwrote the operator's locale: %q", locale) + } + + // A profile the demo already seeded that still sits on the migration's + // en-US default is lifted to en-CA. + if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name='Gable Building Supply Ltd', trading_name='Gable Building Supply', locale='en-US' WHERE id = 1`); err != nil { + t.Fatal(err) + } + seedDealerProfile(sqlDB) + if _, _, locale, _ := read(); locale != "en-CA" { + t.Errorf("the demo default was not lifted to en-CA: %q", locale) } } diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index a463032f2..3c658e38a 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -355,10 +355,13 @@ func (w *branchWall) emailDeliveries(mux *http.ServeMux, h *emaildelivery.Handle // dealerProfile mounts the dealer profile: every role that prints reads it // and its logo, and only admin and owner write them. The profile is one // dealer wide row, so it runs behind the role guard alone and a branch bound -// key is refused the writes by the route allowlist gate. +// key is refused the writes by the route allowlist gate. The locale alone is +// the one read every signed in desk person may call (a cashier, the yard and +// a driver format their dates for it too). func (w *branchWall) dealerProfile(mux *http.ServeMux, h *dealerprofile.Handler) { h.RegisterRoutes(mux, dealerprofile.Guards{ - Read: middleware.RequireRole("admin", "owner", "sales", "finance", "purchasing"), - Write: middleware.RequireRole("admin", "owner"), + Read: middleware.RequireRole("admin", "owner", "sales", "finance", "purchasing"), + Write: middleware.RequireRole("admin", "owner"), + Locale: middleware.RequireRole("admin", "owner", "sales", "finance", "purchasing", "warehouse", "cashier", "driver"), }) } diff --git a/core/internal/app/serve/wire_branch_wall_test.go b/core/internal/app/serve/wire_branch_wall_test.go index 7130a7546..528f59dcc 100644 --- a/core/internal/app/serve/wire_branch_wall_test.go +++ b/core/internal/app/serve/wire_branch_wall_test.go @@ -1857,3 +1857,40 @@ func TestBranchWall_FormsAndDeliveries(t *testing.T) { t.Errorf("the refused foreign quote email left %d delivery rows (%v), want 0", n, err) } } + +// TestBranchWall_DealerProfileLocale: the locale read every signed in desk +// person may call (G1 walk note N15: the pages format their dates for it). +// Every desk role gets 200 with {"locale": ...}; a cashier, the yard and a +// driver included, though the profile read itself still refuses them (its +// guard is unchanged); no role at all is refused. +func TestBranchWall_DealerProfileLocale(t *testing.T) { + db := testutil.RequireDB(t) + f := newWallFixture(t, db, true) + + var want string + if err := db.Pool.QueryRow(context.Background(), `SELECT locale FROM dealer_profile WHERE id = 1`).Scan(&want); err != nil { + t.Fatal(err) + } + for _, role := range []string{"admin", "owner", "sales", "finance", "purchasing", "warehouse", "cashier", "driver"} { + status, buf := f.callBody(t, "GET", "/api/v1/dealer-profile/locale", "", role, "u-"+role, "") + if status != http.StatusOK { + t.Errorf("locale read as %s: %d %s", role, status, buf) + continue + } + var body struct { + Locale string `json:"locale"` + } + if err := json.Unmarshal(buf, &body); err != nil || body.Locale != want { + t.Errorf("locale read as %s: %s (want locale %q)", role, buf, want) + } + } + + // The profile's own read keeps its guard: a cashier is refused it. + if s := f.call(t, "GET", "/api/v1/dealer-profile", "", "cashier", "u-cashier", ""); s != http.StatusForbidden { + t.Errorf("the profile read as cashier: %d, want 403", s) + } + // Signed out (the stand-in auth injects no role) is refused the locale. + if s := f.call(t, "GET", "/api/v1/dealer-profile/locale", "", "", "", ""); s != http.StatusForbidden { + t.Errorf("the locale read with no role: %d, want 403", s) + } +} diff --git a/core/internal/characterization/testdata/goldens/portal.json b/core/internal/characterization/testdata/goldens/portal.json index 65f626795..0b1e49dd9 100644 --- a/core/internal/characterization/testdata/goldens/portal.json +++ b/core/internal/characterization/testdata/goldens/portal.json @@ -12,6 +12,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", @@ -38,6 +39,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/characterization/testdata/goldens/portal_billing.json b/core/internal/characterization/testdata/goldens/portal_billing.json index 741574493..2da201935 100644 --- a/core/internal/characterization/testdata/goldens/portal_billing.json +++ b/core/internal/characterization/testdata/goldens/portal_billing.json @@ -17,6 +17,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/characterization/testdata/goldens/portal_catalog.json b/core/internal/characterization/testdata/goldens/portal_catalog.json index 0fbf1872b..37de26cd3 100644 --- a/core/internal/characterization/testdata/goldens/portal_catalog.json +++ b/core/internal/characterization/testdata/goldens/portal_catalog.json @@ -17,6 +17,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/characterization/testdata/goldens/portal_logout.json b/core/internal/characterization/testdata/goldens/portal_logout.json index bde56d0c4..e38802d1c 100644 --- a/core/internal/characterization/testdata/goldens/portal_logout.json +++ b/core/internal/characterization/testdata/goldens/portal_logout.json @@ -17,6 +17,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/characterization/testdata/goldens/portal_orders.json b/core/internal/characterization/testdata/goldens/portal_orders.json index 8de00e363..8a4fe52f1 100644 --- a/core/internal/characterization/testdata/goldens/portal_orders.json +++ b/core/internal/characterization/testdata/goldens/portal_orders.json @@ -17,6 +17,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/characterization/testdata/goldens/portal_projects.json b/core/internal/characterization/testdata/goldens/portal_projects.json index 2ecb09fcf..29d04a96f 100644 --- a/core/internal/characterization/testdata/goldens/portal_projects.json +++ b/core/internal/characterization/testdata/goldens/portal_projects.json @@ -17,6 +17,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/characterization/testdata/goldens/portal_quotes.json b/core/internal/characterization/testdata/goldens/portal_quotes.json index e54b457d0..ae4ede92f 100644 --- a/core/internal/characterization/testdata/goldens/portal_quotes.json +++ b/core/internal/characterization/testdata/goldens/portal_quotes.json @@ -17,6 +17,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/characterization/testdata/goldens/portal_team.json b/core/internal/characterization/testdata/goldens/portal_team.json index 8a038217c..07e768859 100644 --- a/core/internal/characterization/testdata/goldens/portal_team.json +++ b/core/internal/characterization/testdata/goldens/portal_team.json @@ -17,6 +17,7 @@ "created_at": "", "dealer_name": "Gable Building Supply", "id": "", + "locale": "en-CA", "logo_url": "", "primary_color": "#00FFA3", "support_email": "support@gablebuildingsupply.example", diff --git a/core/internal/dealerprofile/handler.go b/core/internal/dealerprofile/handler.go index e90a5cc03..7b136f6a3 100644 --- a/core/internal/dealerprofile/handler.go +++ b/core/internal/dealerprofile/handler.go @@ -16,10 +16,13 @@ import ( ) // Guards are the role guards the routes mount behind: Read for the profile -// and logo reads (every role that prints), Write for the two writes. +// and logo reads (every role that prints), Write for the two writes, Locale +// for the locale alone (every signed in desk person: the pages format their +// dates for it). type Guards struct { - Read func(http.Handler) http.Handler - Write func(http.Handler) http.Handler + Read func(http.Handler) http.Handler + Write func(http.Handler) http.Handler + Locale func(http.Handler) http.Handler } type Handler struct{ svc *Service } @@ -36,6 +39,7 @@ func guarded(g func(http.Handler) http.Handler, h http.HandlerFunc) http.Handler func (h *Handler) RegisterRoutes(mux *http.ServeMux, g Guards) { mux.HandleFunc("GET /api/v1/dealer-profile", guarded(g.Read, h.Get)) mux.HandleFunc("PUT /api/v1/dealer-profile", guarded(g.Write, h.Put)) + mux.HandleFunc("GET /api/v1/dealer-profile/locale", guarded(g.Locale, h.GetLocale)) mux.HandleFunc("GET /api/v1/dealer-profile/logo", guarded(g.Read, h.GetLogo)) mux.HandleFunc("PUT /api/v1/dealer-profile/logo", guarded(g.Write, h.PutLogo)) } @@ -60,14 +64,30 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, p) } +// GetLocale answers the locale alone, {"locale": "en-CA"}, for the pages +// that format dates. +func (h *Handler) GetLocale(w http.ResponseWriter, r *http.Request) { + if _, err := httpx.StrictQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + locale, err := h.svc.GetLocale(r.Context()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"locale": locale}) +} + type putRequest struct { - LegalName string `json:"legal_name"` - TradingName string `json:"trading_name"` - Phone string `json:"phone"` - Email string `json:"email"` - Website string `json:"website"` - RemitTo string `json:"remit_to"` - Revision *int64 `json:"revision"` + LegalName string `json:"legal_name"` + TradingName string `json:"trading_name"` + Phone string `json:"phone"` + Email string `json:"email"` + Website string `json:"website"` + RemitTo string `json:"remit_to"` + Locale *string `json:"locale"` + Revision *int64 `json:"revision"` } func (req putRequest) parse() (Input, error) { @@ -93,6 +113,14 @@ func (req putRequest) parse() (Input, error) { u, err := url.Parse(in.Website) v.Check(err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "", "website", "must be an http or https address") } + if req.Locale != nil { + locale := strings.TrimSpace(*req.Locale) + if err := CheckLocale(locale); err != nil { + v.Check(false, "locale", err.Error()) + } else { + in.Locale = locale + } + } return in, v.Err() } diff --git a/core/internal/dealerprofile/migration119_test.go b/core/internal/dealerprofile/migration119_test.go new file mode 100644 index 000000000..02551781c --- /dev/null +++ b/core/internal/dealerprofile/migration119_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package dealerprofile_test + +// Migration 119 on rows that exist (the recipe's migration step): the dealer +// profile gains its locale, the tag every date the app shows is formatted +// for. An existing row takes the en-US default (the demo seed lifts the demo +// dealer to en-CA), the shape constraint refuses a tag that is no language +// tag, the down drops the column, and a re-apply works. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/jackc/pgx/v5" +) + +func scratchDB119(t *testing.T) *pgx.Conn { + t.Helper() + db := testutil.RequireDB(t) + base, err := url.Parse(db.Pool.Config().ConnString()) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + admin, err := pgx.Connect(ctx, base.String()) + if err != nil { + t.Fatal(err) + } + suffix := make([]byte, 6) + if _, err := rand.Read(suffix); err != nil { + t.Fatal(err) + } + name := "gv1_g1mig119_" + hex.EncodeToString(suffix) + if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, name)); err != nil { + admin.Close(ctx) + t.Skipf("cannot create a scratch database: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE %q WITH (FORCE)`, name)) + admin.Close(context.Background()) + }) + scratch := *base + scratch.Path = "/" + name + conn, err := pgx.Connect(ctx, scratch.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn +} + +func migration119Files(t *testing.T) (before []string, target, downFile string) { + t.Helper() + all, err := filepath.Glob("../../migrations/*.sql") + if err != nil || len(all) == 0 { + t.Fatalf("no migrations found: %v", err) + } + sort.Strings(all) + for _, f := range all { + base := filepath.Base(f) + switch { + case strings.HasPrefix(base, "119_"): + target = f + case base < "119_": + before = append(before, f) + } + } + if target == "" { + t.Fatal("migration 119 not found") + } + downFile = "../../migrations/down/119_dealer_profile_locale_down.sql" + if _, err := os.Stat(downFile); err != nil { + t.Fatal("migration 119 down not found") + } + return before, target, downFile +} + +func apply119(t *testing.T, conn *pgx.Conn, file string) { + t.Helper() + sql, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), string(sql)); err != nil { + t.Fatalf("applying %s: %v", filepath.Base(file), err) + } +} + +func TestMigration119_DealerProfileLocale(t *testing.T) { + conn := scratchDB119(t) + before, target, downFile := migration119Files(t) + for _, f := range before { + apply119(t, conn, f) + } + ctx := context.Background() + + // The single profile row as an existing database holds it: filled in, no + // locale column yet. + if _, err := conn.Exec(ctx, `UPDATE dealer_profile SET legal_name = 'Mig Yard Ltd', trading_name = 'Mig Yard' WHERE id = 1`); err != nil { + t.Fatal(err) + } + + apply119(t, conn, target) + + var locale string + if err := conn.QueryRow(ctx, `SELECT locale FROM dealer_profile WHERE id = 1`).Scan(&locale); err != nil { + t.Fatalf("the existing row reads no locale: %v", err) + } + if locale != "en-US" { + t.Errorf("the existing row's locale = %q, want the en-US default", locale) + } + if _, err := conn.Exec(ctx, `UPDATE dealer_profile SET locale = 'en-CA' WHERE id = 1`); err != nil { + t.Errorf("a good tag is refused: %v", err) + } + if _, err := conn.Exec(ctx, `UPDATE dealer_profile SET locale = 'not a locale' WHERE id = 1`); err == nil { + t.Error("a tag that is no language tag passed the shape check") + } + + apply119(t, conn, downFile) + var n int + if err := conn.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'dealer_profile' AND column_name = 'locale'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Error("the down left the locale column behind") + } + + apply119(t, conn, target) + if err := conn.QueryRow(ctx, `SELECT locale FROM dealer_profile WHERE id = 1`).Scan(&locale); err != nil || locale != "en-US" { + t.Errorf("a re-apply: locale = %q, %v", locale, err) + } +} diff --git a/core/internal/dealerprofile/model.go b/core/internal/dealerprofile/model.go index c00a519c2..03e34bd89 100644 --- a/core/internal/dealerprofile/model.go +++ b/core/internal/dealerprofile/model.go @@ -2,8 +2,9 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors // Package dealerprofile holds the one dealer wide profile every printed form -// shows: the dealer's legal and trading name, contact lines, remit to address -// and logo. It is a single row (migration 115). +// shows: the dealer's legal and trading name, contact lines, remit to address, +// logo and locale (the BCP 47 tag every date the app shows is formatted for). +// It is a single row (migration 115; the locale column is migration 119). package dealerprofile import ( @@ -17,6 +18,7 @@ import ( "strings" "github.com/gablelbm/gable/internal/platform/httpx" + "golang.org/x/text/language" ) // LogoPath is the route the logo is served from; Profile.LogoURL carries it @@ -37,6 +39,7 @@ type Profile struct { Email string `json:"email"` Website string `json:"website"` RemitTo string `json:"remit_to"` + Locale string `json:"locale"` LogoURL *string `json:"logo_url"` Revision int64 `json:"revision"` UpdatedAt httpx.Timestamp `json:"updated_at"` @@ -50,7 +53,9 @@ func (p Profile) DisplayName() string { return strings.TrimSpace(p.LegalName) } -// Input is the writable slice of the profile. +// Input is the writable slice of the profile. An empty Locale leaves the +// stored one alone (the service fills it from the locked row), so a caller +// from before the field existed never resets it. type Input struct { LegalName string TradingName string @@ -58,6 +63,7 @@ type Input struct { Email string Website string RemitTo string + Locale string } // Logo is the stored image. @@ -66,6 +72,26 @@ type Logo struct { ContentType string } +// MaxLocaleTagLength caps a locale tag (BCP 47's own suggestion is 35). +const MaxLocaleTagLength = 35 + +// CheckLocale accepts a BCP 47 tag the date formatter can use ("en-US", +// "en-CA", "fr-CA") and refuses anything else in words. The canonical form +// the parser would rewrite to (it title cases the region, for example) is +// not required; the tag is stored as given. +func CheckLocale(tag string) error { + if tag == "" { + return errors.New("the locale must be a language tag such as en-US") + } + if len(tag) > MaxLocaleTagLength { + return fmt.Errorf("the locale must be at most %d characters", MaxLocaleTagLength) + } + if _, err := language.Parse(tag); err != nil { + return fmt.Errorf("the locale %q is not a language tag the date formatter accepts (a BCP 47 tag such as en-US)", tag) + } + return nil +} + // Reader is what a printing module needs: the profile and its logo. type Reader interface { Get(ctx context.Context) (*Profile, error) diff --git a/core/internal/dealerprofile/repository.go b/core/internal/dealerprofile/repository.go index d904f9538..80abdeb9c 100644 --- a/core/internal/dealerprofile/repository.go +++ b/core/internal/dealerprofile/repository.go @@ -28,13 +28,13 @@ type PostgresRepository struct{ db *database.DB } func NewRepository(db *database.DB) *PostgresRepository { return &PostgresRepository{db: db} } -const columns = `legal_name, trading_name, phone, email, website, remit_to, +const columns = `legal_name, trading_name, phone, email, website, remit_to, locale, CASE WHEN logo IS NULL THEN NULL ELSE '` + LogoPath + `' END, revision, updated_at` func scan(row pgx.Row) (*Profile, error) { var p Profile var updated = new(httpx.Timestamp) - if err := row.Scan(&p.LegalName, &p.TradingName, &p.Phone, &p.Email, &p.Website, &p.RemitTo, &p.LogoURL, &p.Revision, &updated.Time); err != nil { + if err := row.Scan(&p.LegalName, &p.TradingName, &p.Phone, &p.Email, &p.Website, &p.RemitTo, &p.Locale, &p.LogoURL, &p.Revision, &updated.Time); err != nil { return nil, err } p.UpdatedAt = *updated @@ -60,9 +60,9 @@ func (r *PostgresRepository) Lock(ctx context.Context) (*Profile, error) { func (r *PostgresRepository) Update(ctx context.Context, in Input) (*Profile, error) { p, err := scan(r.db.GetExecutor(ctx).QueryRow(ctx, ` UPDATE dealer_profile SET legal_name = $1, trading_name = $2, phone = $3, email = $4, - website = $5, remit_to = $6, revision = revision + 1, updated_at = NOW() + website = $5, remit_to = $6, locale = $7, revision = revision + 1, updated_at = NOW() WHERE id = 1 RETURNING `+columns, - in.LegalName, in.TradingName, in.Phone, in.Email, in.Website, in.RemitTo)) + in.LegalName, in.TradingName, in.Phone, in.Email, in.Website, in.RemitTo, in.Locale)) if err != nil { return nil, fmt.Errorf("update dealer profile: %w", err) } diff --git a/core/internal/dealerprofile/service.go b/core/internal/dealerprofile/service.go index 41511b45e..c162d111d 100644 --- a/core/internal/dealerprofile/service.go +++ b/core/internal/dealerprofile/service.go @@ -35,12 +35,22 @@ func NewService(repo Repository, tx TxRunner, a *audit.Logger) *Service { func (s *Service) Get(ctx context.Context) (*Profile, error) { return s.repo.Get(ctx) } +// GetLocale reads only the locale, for the route every signed in person may +// call (the profile read itself is held to the roles that print). +func (s *Service) GetLocale(ctx context.Context) (string, error) { + p, err := s.repo.Get(ctx) + if err != nil { + return "", err + } + return p.Locale, nil +} + func (s *Service) GetLogo(ctx context.Context) (*Logo, error) { return s.repo.GetLogo(ctx) } func fields(p *Profile) map[string]any { return map[string]any{ "legal_name": p.LegalName, "trading_name": p.TradingName, "phone": p.Phone, - "email": p.Email, "website": p.Website, "remit_to": p.RemitTo, "has_logo": p.LogoURL != nil, + "email": p.Email, "website": p.Website, "remit_to": p.RemitTo, "locale": p.Locale, "has_logo": p.LogoURL != nil, } } @@ -57,6 +67,9 @@ func (s *Service) Update(ctx context.Context, in Input, ifMatch string, bodyRevi if err := httpx.CheckRevision(cur.Revision, ifMatch, bodyRevision); err != nil { return err } + if in.Locale == "" { + in.Locale = cur.Locale + } next, err := s.repo.Update(ctx, in) if err != nil { return err diff --git a/core/internal/dealerprofile/wire_test.go b/core/internal/dealerprofile/wire_test.go index 79fe0754e..dc0995b3c 100644 --- a/core/internal/dealerprofile/wire_test.go +++ b/core/internal/dealerprofile/wire_test.go @@ -49,17 +49,18 @@ func newFixture(t *testing.T, maxConns int32) *fixture { } var snapshot struct { legal, trading, phone, email, website, remit string + locale string logo []byte ctype *string revision int64 } - if err := db.Pool.QueryRow(context.Background(), `SELECT legal_name, trading_name, phone, email, website, remit_to, logo, logo_content_type, revision FROM dealer_profile WHERE id = 1`). - Scan(&snapshot.legal, &snapshot.trading, &snapshot.phone, &snapshot.email, &snapshot.website, &snapshot.remit, &snapshot.logo, &snapshot.ctype, &snapshot.revision); err != nil { + if err := db.Pool.QueryRow(context.Background(), `SELECT legal_name, trading_name, phone, email, website, remit_to, locale, logo, logo_content_type, revision FROM dealer_profile WHERE id = 1`). + Scan(&snapshot.legal, &snapshot.trading, &snapshot.phone, &snapshot.email, &snapshot.website, &snapshot.remit, &snapshot.locale, &snapshot.logo, &snapshot.ctype, &snapshot.revision); err != nil { t.Fatal(err) } t.Cleanup(func() { - _, _ = db.Pool.Exec(context.Background(), `UPDATE dealer_profile SET legal_name=$1, trading_name=$2, phone=$3, email=$4, website=$5, remit_to=$6, logo=$7, logo_content_type=$8, revision=$9 WHERE id = 1`, - snapshot.legal, snapshot.trading, snapshot.phone, snapshot.email, snapshot.website, snapshot.remit, snapshot.logo, snapshot.ctype, snapshot.revision) + _, _ = db.Pool.Exec(context.Background(), `UPDATE dealer_profile SET legal_name=$1, trading_name=$2, phone=$3, email=$4, website=$5, remit_to=$6, locale=$7, logo=$8, logo_content_type=$9, revision=$10 WHERE id = 1`, + snapshot.legal, snapshot.trading, snapshot.phone, snapshot.email, snapshot.website, snapshot.remit, snapshot.locale, snapshot.logo, snapshot.ctype, snapshot.revision) }) svc := dealerprofile.NewService(dealerprofile.NewRepository(db), db, audit.NewLogger(db)) mux := http.NewServeMux() @@ -137,7 +138,7 @@ func TestDealerProfileReadShape(t *testing.T) { if r.status != http.StatusOK { t.Fatalf("get: %d %s", r.status, r.raw) } - for _, k := range []string{"legal_name", "trading_name", "phone", "email", "website", "remit_to", "logo_url", "revision", "updated_at"} { + for _, k := range []string{"legal_name", "trading_name", "phone", "email", "website", "remit_to", "locale", "logo_url", "revision", "updated_at"} { if _, ok := r.body[k]; !ok { t.Errorf("field %q missing from %s", k, r.raw) } @@ -197,6 +198,51 @@ func TestDealerProfileWriteNeedsRevisionAndAudits(t *testing.T) { } } +// TestDealerProfileLocale: the locale rides the profile read, the locale +// route answers it alone, the write validates the tag (a BCP 47 tag such as +// en-CA, refused in words otherwise), and a write without the field keeps the +// stored one. +func TestDealerProfileLocale(t *testing.T) { + f := newFixture(t, 0) + + stored := f.do("GET", "/api/v1/dealer-profile", nil) + if _, ok := stored.body["locale"].(string); !ok { + t.Fatalf("the profile read carries no locale: %s", stored.raw) + } + + l := f.do("GET", "/api/v1/dealer-profile/locale", nil) + if l.status != http.StatusOK || l.body["locale"] != stored.body["locale"] || len(l.body) != 1 { + t.Errorf("the locale read: %d %s", l.status, l.raw) + } + if r := f.do("GET", "/api/v1/dealer-profile/locale?bogus=1", nil); r.status != http.StatusBadRequest { + t.Errorf("unknown parameter: %d", r.status) + } + + rev := f.revision() + withLocale := strings.Replace(goodBody, `"remit_to":"PO Box 1\nTesttown"}`, `"remit_to":"PO Box 1\nTesttown","locale":"en-CA"}`, 1) + r := f.put(withLocale, "If-Match", `"`+strconv.FormatInt(rev, 10)+`"`) + if r.status != http.StatusOK || r.body["locale"] != "en-CA" { + t.Fatalf("write with a good tag: %d %s", r.status, r.raw) + } + if l := f.do("GET", "/api/v1/dealer-profile/locale", nil); l.body["locale"] != "en-CA" { + t.Errorf("the locale read after the write: %s", l.raw) + } + + r = f.put(strings.Replace(withLocale, `"en-CA"`, `"not a locale"`, 1), "If-Match", `"`+strconv.FormatInt(rev+1, 10)+`"`) + if r.status != http.StatusBadRequest { + t.Fatalf("a bad tag: %d %s", r.status, r.raw) + } + if d, _ := json.Marshal(r.body["error"]); !strings.Contains(string(d), "locale") { + t.Errorf("the refusal does not name the field: %s", d) + } + + // A write without the field (a caller from before it existed) keeps en-CA. + r = f.put(goodBody, "If-Match", `"`+strconv.FormatInt(f.revision(), 10)+`"`) + if r.status != http.StatusOK || r.body["locale"] != "en-CA" { + t.Errorf("write without the field: %d %s", r.status, r.raw) + } +} + func TestDealerProfileLogo(t *testing.T) { f := newFixture(t, 0) rev := f.revision() diff --git a/core/internal/portal/model.go b/core/internal/portal/model.go index 56e44f8fa..4086c4bde 100644 --- a/core/internal/portal/model.go +++ b/core/internal/portal/model.go @@ -33,7 +33,8 @@ type PortalInvite struct { CreatedAt time.Time `json:"created_at"` } -// PortalConfig holds white-label branding for the dealer portal. +// PortalConfig holds white-label branding for the dealer portal. The locale +// is the dealer profile's (the portal's pages format their dates for it). type PortalConfig struct { ID uuid.UUID `json:"id"` DealerName string `json:"dealer_name"` @@ -41,6 +42,7 @@ type PortalConfig struct { PrimaryColor string `json:"primary_color"` SupportEmail string `json:"support_email"` SupportPhone string `json:"support_phone"` + Locale string `json:"locale"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/core/internal/portal/repository.go b/core/internal/portal/repository.go index c89862278..857dc8f71 100644 --- a/core/internal/portal/repository.go +++ b/core/internal/portal/repository.go @@ -117,17 +117,20 @@ func (r *PostgresRepository) GetCustomerUserByEmail(ctx context.Context, email s return &u, nil } -// GetPortalConfig fetches the first (singleton) portal config row. +// GetPortalConfig fetches the first (singleton) portal config row. The locale +// is not the row's own: it is the dealer profile's, defaulting to en-US. func (r *PostgresRepository) GetPortalConfig(ctx context.Context) (*PortalConfig, error) { query := ` - SELECT id, dealer_name, logo_url, primary_color, support_email, support_phone, created_at, updated_at + SELECT id, dealer_name, logo_url, primary_color, support_email, support_phone, + COALESCE((SELECT locale FROM dealer_profile WHERE id = 1), 'en-US'), + created_at, updated_at FROM portal_config LIMIT 1 ` var cfg PortalConfig err := r.db.GetExecutor(ctx).QueryRow(ctx, query).Scan( &cfg.ID, &cfg.DealerName, &cfg.LogoURL, &cfg.PrimaryColor, - &cfg.SupportEmail, &cfg.SupportPhone, &cfg.CreatedAt, &cfg.UpdatedAt, + &cfg.SupportEmail, &cfg.SupportPhone, &cfg.Locale, &cfg.CreatedAt, &cfg.UpdatedAt, ) if err != nil { if err == pgx.ErrNoRows { diff --git a/core/migrations/119_dealer_profile_locale.sql b/core/migrations/119_dealer_profile_locale.sql new file mode 100644 index 000000000..acc86e61d --- /dev/null +++ b/core/migrations/119_dealer_profile_locale.sql @@ -0,0 +1,18 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Migration: 119_dealer_profile_locale +-- Description: the dealer's locale on the one dealer wide profile (migration +-- 115). Every date the app shows is formatted for this locale; it defaults to +-- en-US and the demo seed sets en-CA. The shape check here is only the tag's +-- outline; the write route validates the tag itself. + +ALTER TABLE dealer_profile + ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en-US'; + +ALTER TABLE dealer_profile + DROP CONSTRAINT IF EXISTS dealer_profile_locale_shape; + +ALTER TABLE dealer_profile + ADD CONSTRAINT dealer_profile_locale_shape + CHECK (locale ~ '^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$'); diff --git a/core/migrations/down/119_dealer_profile_locale_down.sql b/core/migrations/down/119_dealer_profile_locale_down.sql new file mode 100644 index 000000000..0421aa3d3 --- /dev/null +++ b/core/migrations/down/119_dealer_profile_locale_down.sql @@ -0,0 +1,9 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Rollback for 119_dealer_profile_locale.sql. +-- +-- Lives in migrations/down/ so cmd/migrate's `migrations/*.sql` glob cannot +-- pick it up and apply it as a forward migration. Apply by hand. + +ALTER TABLE dealer_profile DROP COLUMN IF EXISTS locale; diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 6a9be5491..6acbcc0d1 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -283,6 +283,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/products/{id}/units": {KeyBranchClassStatedLimit, "the dealer wide product unit sets"}, "GET /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, "GET /api/v1/dealer-profile": {KeyBranchClassStatedLimit, "the one dealer wide profile every printed form shows"}, + "GET /api/v1/dealer-profile/locale": {KeyBranchClassStatedLimit, "the one dealer wide locale, read alone so every role's pages format their dates for it"}, "GET /api/v1/dealer-profile/logo": {KeyBranchClassStatedLimit, "the one dealer wide profile every printed form shows"}, "GET /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, "GET /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 147c70b8f..4e25c865f 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -426,4 +426,5 @@ get an entry. | SEED-HYGIENE (items 108 and 109) | the base seed's product names and SKU, seen by the `dashboard`, `pos`, `portal_quotes` and `purchase_order_flow` goldens (no route or shape changes) | The seed carried real brand words in product names and one SKU: `Simpson Strong-Tie LUS28` (`SIMP-LUS28`), `Cemtrim Textured ...`, `... Soffit Hardie Textured ...`, `5/8 Drywall Firecode 4x8` and `... NSP DRYLine TSX`, and `Ice & Water Shield` (`RF-ICE-65`, `RF-ICE-WTR`). | The same products under plain names: `Face Mount Joist Hanger 2x8` (`HANGER-28FM`), `Composite Trim Textured ...`, `... Soffit Fibre Cement Textured ...`, `5/8 Drywall Type X 4x8` and `... NSP Weather Barrier`, and `Ice and Water Barrier` for the two ice and water rows (the dashboard and pos goldens). Products list in SKU order, so the renamed SKU moves one row and the `` placeholders after it shift by one; no value other than these names, that SKU and the placeholders moved. | | SEED-HYGIENE (item 108 b) | migration 118 `staff_demo_domain` (operational, no route) | Migration 080 left three demo staff on `@gable.com`, a real domain. | Migration 118 rewrites those three addresses to `@gablebuildingsupply.example`; staff ids and grants are unchanged. | | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | +| 130 (N15) | `GET /api/v1/dealer-profile` and `PUT /api/v1/dealer-profile`; `GET /api/v1/dealer-profile/locale` (new route); `GET /api/portal/v1/config` and the portal login's `config` | The dealer profile carried no locale, so every page took the browser's; no route answered the locale alone. | The profile carries `locale` (a BCP 47 tag, migration 119, default `en-US`; the PUT validates the tag and a write that leaves it out keeps the stored one; the demo seed sets `en-CA`). The new locale read answers `{"locale": "en-CA"}` to every signed in desk role (the profile read's own guard is unchanged) and is a stated limit in the bound key allowlist. The portal's public config and the login response's embedded config carry the same `locale`. In the goldens, `locale: "en-CA"` is added to the portal config and login responses (the seed sets the demo dealer en-CA); no other golden moved. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 84c3cf3fc..a34f7f84e 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -1304,7 +1304,7 @@ export interface paths { get: operations["dealerProfileGet"]; /** * Replace the dealer profile - * @description Replaces the writable fields (the logo has its own route). Role gated admin and owner. The revision precondition applies: If-Match or the body revision, 428 without either, 409 stale_revision when stale. A branch bound machine key is refused. The write is audited with the before and after in one transaction. + * @description Replaces the writable fields (the logo has its own route; a locale left out keeps the stored one). Role gated admin and owner. The revision precondition applies: If-Match or the body revision, 428 without either, 409 stale_revision when stale. A branch bound machine key is refused. The write is audited with the before and after in one transaction. */ put: operations["dealerProfilePut"]; post?: never; @@ -1314,6 +1314,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/dealer-profile/locale": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read the dealer's locale alone + * @description The locale alone, {"locale": "en-CA"}: every signed in desk person may call it (the profile read itself is held to the roles that print), so every page can format its dates for the dealer's locale. + */ + get: operations["dealerProfileLocaleGet"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/dealer-profile/logo": { parameters: { query?: never; @@ -4103,7 +4123,7 @@ export interface paths { }; /** * The dealer branding config - * @description Public. The one row of white-label branding the login page renders before any session exists. + * @description Public. The one row of white-label branding the login page renders before any session exists, with the dealer's locale for the portal's dates. */ get: operations["portalGetConfig"]; put?: never; @@ -8580,6 +8600,10 @@ export interface components { */ revenue: number; }; + DealerProfileLocale: { + /** @description The dealer's locale (a BCP 47 tag). */ + locale: string; + }; DealerProfile: { legal_name: string; trading_name: string; @@ -8588,6 +8612,8 @@ export interface components { website: string; /** @description The address payments are sent to; invoices and statements print it. */ remit_to: string; + /** @description The dealer's locale (a BCP 47 tag, default en-US); every date the app shows is formatted for it. */ + locale: string; /** @description /api/v1/dealer-profile/logo when a logo is set, otherwise null. */ logo_url: string | null; /** Format: int64 */ @@ -8603,6 +8629,8 @@ export interface components { /** @description An http or https address, or empty. */ website?: string; remit_to?: string; + /** @description A BCP 47 tag (en-US, en-CA), refused in words otherwise. Leave it out to keep the stored one. */ + locale?: string; /** * Format: int64 * @description The precondition, beside If-Match. @@ -11628,6 +11656,8 @@ export interface components { primary_color: string; support_email: string; support_phone: string; + /** @description The dealer profile's locale (a BCP 47 tag); the portal's pages format their dates for it. */ + locale: string; /** Format: date-time */ created_at: string; /** Format: date-time */ @@ -17999,6 +18029,30 @@ export interface operations { 500: components["responses"]["WireInternalError"]; }; }; + dealerProfileLocaleGet: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The dealer's locale. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DealerProfileLocale"]; + }; + }; + 400: components["responses"]["WireBadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["WireInternalError"]; + }; + }; dealerProfileLogoGet: { parameters: { query?: never; From fa366eaa00fcfa7b62243ee188f4e0d9d70c4349 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:37:12 -0700 Subject: [PATCH 03/31] web: one date module and the app's own date field, swept (G1 walk note N15) lib/dates.ts reads the dealer's locale once at start (the desk route for the desk's pages, the portal's public config for the portal's; en-US on any failure, quietly) and formats every date in one style: the dealer's locale with the month as a short word, never a numeric month first or day first form. formatDay keeps the business day rule (built from its parts, so a west of UTC machine never shows the day before) and moves here from utils.ts, which re-exports it; formatDate and formatDateTime read a moment in the person's own time zone; parseDay reads a typed day in the module's style or YYYY-MM-DD. gable-date-field is the app's own date field: a text box in the module's style with the calendar in the app's own popover (the week starts as the locale starts it; arrows a day or a week, Page Up and Page Down a month, Home and End the week, Enter picks, Escape closes; today marked; min and max honoured), the value always YYYY-MM-DD for the API, change fired on a commit, and an empty field saying Choose a date, never mm/dd/yyyy. The sweep: every native date and datetime-local input in the desk is the field now, with the same values, defaults and change handling (the defaults that built today from UTC take todayDay/addDays, so they hold west of UTC too); every date shown to a person goes through the module, including the spots that showed the raw YYYY-MM-DD (the statement's lines, the aging header, the quote analytics axis, the daily till) and the two date-fns call sites. The guard (dates.guard.test.ts) is green on this tree. Signed-off-by: colton --- .../src/components/crm/LogActivityModal.ts | 27 +- .../components/dashboard/RecentOrdersFeed.ts | 3 +- .../components/logistics/CreateRouteModal.ts | 15 +- .../src/components/logistics/DeliveryList.ts | 3 +- .../src/components/logistics/RouteList.ts | 6 +- .../src/components/quotes/EscalatorToggle.ts | 21 +- .../components/ui/gable-date-field.test.ts | 216 +++++++++++ .../src/components/ui/gable-date-field.ts | 337 ++++++++++++++++++ web/apps/desk/src/lib/credit-display.test.ts | 5 +- web/apps/desk/src/lib/dates.test.ts | 184 ++++++++++ web/apps/desk/src/lib/dates.ts | 233 ++++++++++++ web/apps/desk/src/lib/utils.ts | 13 +- web/apps/desk/src/main.ts | 15 +- web/apps/desk/src/pages/DailyTill.ts | 3 +- web/apps/desk/src/pages/Dashboard.ts | 5 +- web/apps/desk/src/pages/DispatchBoard.ts | 3 +- web/apps/desk/src/pages/QuoteBuilder.ts | 14 +- .../pages/accounting/AccountsPayable.test.ts | 14 +- .../src/pages/accounting/AccountsPayable.ts | 73 ++-- .../desk/src/pages/accounting/BalanceSheet.ts | 11 +- .../pages/accounting/BankReconciliation.ts | 17 +- .../src/pages/accounting/JournalEntries.ts | 14 +- .../desk/src/pages/accounting/POMatching.ts | 3 +- .../src/pages/accounting/ProfitAndLoss.ts | 20 +- .../desk/src/pages/accounting/TrialBalance.ts | 11 +- .../src/pages/accounts/AccountDetailPage.ts | 3 +- .../desk/src/pages/accounts/ActivityFeed.ts | 4 +- .../src/pages/admin/branches/BranchUsers.ts | 6 +- .../src/pages/admin/pricing/RuleDrawer.ts | 4 +- .../pages/admin/tech_admin/TechAdminPage.ts | 7 +- .../pages/credit-memos/CreditMemoDetail.ts | 3 +- web/apps/desk/src/pages/driver/RouteList.ts | 3 +- .../desk/src/pages/governance/RFCDashboard.ts | 3 +- .../desk/src/pages/governance/RFCDetail.ts | 5 +- .../desk/src/pages/invoices/InvoiceDetail.ts | 9 +- .../src/pages/logistics/FleetManagement.ts | 9 +- web/apps/desk/src/pages/orders/OrderDetail.ts | 3 +- web/apps/desk/src/pages/orders/OrderList.ts | 3 +- .../desk/src/pages/portal/PortalDashboard.ts | 3 +- .../desk/src/pages/portal/PortalDeliveries.ts | 9 +- .../desk/src/pages/portal/PortalInvoices.ts | 7 +- .../desk/src/pages/portal/PortalOrders.ts | 3 +- web/apps/desk/src/pages/portal/PortalTeam.ts | 3 +- .../src/pages/projects/ProjectDashboard.ts | 3 +- .../desk/src/pages/projects/ProjectList.ts | 4 +- .../src/pages/purchasing/PurchaseOrderList.ts | 3 +- .../desk/src/pages/quotes/QuoteAnalytics.ts | 7 +- web/apps/desk/src/pages/quotes/QuoteDetail.ts | 5 +- web/apps/desk/src/pages/quotes/QuoteList.ts | 3 +- .../desk/src/pages/reports/ARAgingReport.ts | 12 +- .../pages/reports/CustomerStatementPage.ts | 29 +- web/apps/desk/src/pages/yard/PickQueue.ts | 3 +- web/apps/desk/src/pages/yard/ReceivePO.ts | 3 +- 53 files changed, 1220 insertions(+), 205 deletions(-) create mode 100644 web/apps/desk/src/components/ui/gable-date-field.test.ts create mode 100644 web/apps/desk/src/components/ui/gable-date-field.ts create mode 100644 web/apps/desk/src/lib/dates.test.ts create mode 100644 web/apps/desk/src/lib/dates.ts diff --git a/web/apps/desk/src/components/crm/LogActivityModal.ts b/web/apps/desk/src/components/crm/LogActivityModal.ts index dfaecc8d1..223c40459 100644 --- a/web/apps/desk/src/components/crm/LogActivityModal.ts +++ b/web/apps/desk/src/components/crm/LogActivityModal.ts @@ -6,6 +6,9 @@ import { customElement, property, state } from 'lit/decorators.js'; import type { CreateActivityRequest, Contact, ActivityType } from '../../types/crm'; import { crmApi } from '../../services/crmApi'; import { ToastService } from '../../lib/toast-service'; +import { todayDay } from '../../lib/dates.ts'; +import '../ui/gable-date-field.ts'; +import type { GableDateField } from '../ui/gable-date-field.ts'; const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'email', 'note']; @@ -19,17 +22,22 @@ export class GableLogActivityModal extends LitElement { @state() private _loading = false; @state() private _activityType: ActivityType = 'call'; @state() private _description = ''; - @state() private _activityDate = new Date().toISOString().slice(0, 16); + @state() private _activityDay = todayDay(); @state() private _contactId = ''; private async _handleSubmit(e: Event) { e.preventDefault(); try { + if (!this._activityDay) { + ToastService.show('Choose a date', 'error'); + return; + } this._loading = true; const payload: CreateActivityRequest = { activity_type: this._activityType, description: this._description, - activity_date: new Date(this._activityDate).toISOString(), + // The day the person picked, at noon so the day survives every time zone's round trip. + activity_date: new Date(`${this._activityDay}T12:00:00`).toISOString(), contact_id: this._contactId === '' ? undefined : this._contactId, }; await crmApi.createActivity(this.customerId, payload); @@ -76,15 +84,12 @@ export class GableLogActivityModal extends LitElement {
- - this._activityDate = (e.target as HTMLInputElement).value} - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" - /> + + this._activityDay = (e.target as GableDateField).value} + >
diff --git a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts index 5b98c36a2..ba532a6de 100644 --- a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts +++ b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts @@ -4,6 +4,7 @@ import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RecentOrder } from '../../types/dashboard.ts'; +import { formatDate } from '../../lib/dates.ts'; function getStatusColor(status: string): string { switch (status.toLowerCase()) { @@ -68,7 +69,7 @@ export class GableRecentOrdersFeed extends LitElement {
-
${new Date(order.created_at).toLocaleDateString()}
+
${formatDate(order.created_at)}
$${(order.total_amount / 100).toLocaleString(undefined, { minimumFractionDigits: 2 })}
diff --git a/web/apps/desk/src/components/logistics/CreateRouteModal.ts b/web/apps/desk/src/components/logistics/CreateRouteModal.ts index 15185c3a0..cf9a0d44a 100644 --- a/web/apps/desk/src/components/logistics/CreateRouteModal.ts +++ b/web/apps/desk/src/components/logistics/CreateRouteModal.ts @@ -7,6 +7,9 @@ import { icon } from '../../lib/icons'; import { X, Truck, User, Calendar, FileText } from 'lucide'; import { deliveryService } from '../../services/deliveryService'; import { ToastService } from '../../lib/toast-service'; +import { todayDay } from '../../lib/dates.ts'; +import '../ui/gable-date-field.ts'; +import type { GableDateField } from '../ui/gable-date-field.ts'; import type { Vehicle, Driver, CreateRouteRequest } from '../../types/delivery'; @customElement('gable-create-route-modal') @@ -19,7 +22,7 @@ export class GableCreateRouteModal extends LitElement { @state() private _drivers: Driver[] = []; @state() private _vehicleId = ''; @state() private _driverId = ''; - @state() private _scheduledDate = new Date().toISOString().split('T')[0]; + @state() private _scheduledDate = todayDay(); @state() private _notes = ''; @state() private _saving = false; @@ -138,13 +141,11 @@ export class GableCreateRouteModal extends LitElement { - this._scheduledDate = (e.target as HTMLInputElement).value} - required - class="w-full bg-[#0A0B10] border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:border-gable-green/50 focus:outline-none" - /> + @change=${(e: Event) => this._scheduledDate = (e.target as GableDateField).value} + >
diff --git a/web/apps/desk/src/components/logistics/DeliveryList.ts b/web/apps/desk/src/components/logistics/DeliveryList.ts index fc79bd40c..7f6fbad5d 100644 --- a/web/apps/desk/src/components/logistics/DeliveryList.ts +++ b/web/apps/desk/src/components/logistics/DeliveryList.ts @@ -12,6 +12,7 @@ import { MapPin, Box, FileText, ArrowRight, ArrowUp, ArrowDown, RotateCcw, Navig import type { Delivery, RouteStatus } from '../../types/delivery'; import { deliveryService } from '../../services/deliveryService'; import { ToastService } from '../../lib/toast-service'; +import { formatTime } from '../../lib/dates.ts'; @customElement('gable-delivery-list') export class GableDeliveryList extends LitElement { @@ -278,7 +279,7 @@ export class GableDeliveryList extends LitElement { ${delivery.estimated_arrival ? html` ${icon(Clock, 12)} - ETA ${new Date(delivery.estimated_arrival).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + ETA ${formatTime(delivery.estimated_arrival)} ` : nothing}
diff --git a/web/apps/desk/src/components/logistics/RouteList.ts b/web/apps/desk/src/components/logistics/RouteList.ts index 3053fd4fb..482e5ac5d 100644 --- a/web/apps/desk/src/components/logistics/RouteList.ts +++ b/web/apps/desk/src/components/logistics/RouteList.ts @@ -12,10 +12,10 @@ import { Plus, User, Truck, Clock, MapPin } from 'lucide'; import type { Route, RouteStatus } from '../../types/delivery'; import { deliveryService } from '../../services/deliveryService'; import { ToastService } from '../../lib/toast-service'; +import { formatDay } from '../../lib/dates.ts'; -const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -}; +// A route's scheduled day, in the one style every page shares. +const formatDate = (dateString: string) => formatDay(dateString); function statusBadgeClass(status: RouteStatus): string { switch (status) { diff --git a/web/apps/desk/src/components/quotes/EscalatorToggle.ts b/web/apps/desk/src/components/quotes/EscalatorToggle.ts index 572ce0bd8..48802d1d9 100644 --- a/web/apps/desk/src/components/quotes/EscalatorToggle.ts +++ b/web/apps/desk/src/components/quotes/EscalatorToggle.ts @@ -7,6 +7,9 @@ import { icon } from '../../lib/icons'; import { TrendingUp, AlertTriangle, Link2, Calendar, Percent, BarChart3 } from 'lucide'; import { PricingService } from '../../services/pricing.service'; import { ToastService } from '../../lib/toast-service'; +import { todayDay, addDays } from '../../lib/dates.ts'; +import '../ui/gable-date-field.ts'; +import type { GableDateField } from '../ui/gable-date-field.ts'; import type { EscalationType, MarketIndex, EscalationResult, QuoteLineEscalator } from '../../types/pricing'; @customElement('gable-escalator-toggle') @@ -40,8 +43,8 @@ export class GableEscalatorToggle extends LitElement { } private _handleToggle() { - const today = new Date().toISOString().split('T')[0]; - const threeMonthsOut = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const today = todayDay(); + const threeMonthsOut = addDays(today, 90); this._emit({ ...this.escalator, @@ -170,19 +173,17 @@ export class GableEscalatorToggle extends LitElement { - this._handleDateChange('effective_date', (e.target as HTMLInputElement).value)} - class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-gable-green/50 focus:outline-none focus:ring-1 focus:ring-gable-green/20 transition-colors" - /> + this._handleDateChange('effective_date', (e.target as GableDateField).value)} + >
- this._handleDateChange('target_date', (e.target as HTMLInputElement).value)} - class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-gable-green/50 focus:outline-none focus:ring-1 focus:ring-gable-green/20 transition-colors" - /> + this._handleDateChange('target_date', (e.target as GableDateField).value)} + >
diff --git a/web/apps/desk/src/components/ui/gable-date-field.test.ts b/web/apps/desk/src/components/ui/gable-date-field.test.ts new file mode 100644 index 000000000..50d046b74 --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-date-field.test.ts @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The app's date field's legs (G1 walk note N15): typing both forms (the + * module's style and YYYY-MM-DD), the calendar's keys (arrows a day or a + * week, Page Up and Page Down a month, Home and End the week, Enter picks, + * Escape closes), min and max honoured, today marked, the week starting as + * the locale starts it, and an empty field that says what to do in words, + * never "mm/dd/yyyy". + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { GableDateField } from './gable-date-field' +import './gable-date-field' +import { setDealerLocale } from '../../lib/dates' +import { mount, update, q } from '../../test/dom' + +const TAG = 'gable-date-field' + +beforeEach(() => setDealerLocale('en-US')) +afterEach(() => { + document.body.innerHTML = '' + vi.useRealTimers() +}) + +function input(el: GableDateField): HTMLInputElement { + return q(el, 'input[type="text"]') +} + +async function type(el: GableDateField, text: string) { + const box = input(el) + box.value = text + box.dispatchEvent(new Event('input')) + box.dispatchEvent(new FocusEvent('blur')) + await update(el, {}) +} + +async function openCalendar(el: GableDateField) { + q(el, 'button[aria-label="Open the calendar"]').click() + await update(el, {}) +} + +async function key(el: GableDateField, k: string, shift = false) { + q(el, '[role="grid"]').dispatchEvent( + new KeyboardEvent('keydown', { key: k, shiftKey: shift, bubbles: true }), + ) + await update(el, {}) +} + +function gridDays(el: GableDateField): HTMLButtonElement[] { + return Array.from(el.querySelectorAll('[role="gridcell"] button')) +} + +describe('typing', () => { + it('takes the module style and fires change with YYYY-MM-DD', async () => { + const el = await mount(TAG) + const seen: string[] = [] + el.addEventListener('change', () => seen.push(el.value)) + await type(el, 'Oct 14, 2026') + expect(el.value).toBe('2026-10-14') + expect(seen).toEqual(['2026-10-14']) + expect(input(el).value).toBe('Oct 14, 2026') + }) + + it('takes YYYY-MM-DD', async () => { + const el = await mount(TAG) + await type(el, '2026-10-14') + expect(el.value).toBe('2026-10-14') + expect(input(el).value).toBe('Oct 14, 2026') + }) + + it('reads back in the dealer locale', async () => { + setDealerLocale('en-GB') + const el = await mount(TAG, { value: '2026-10-14' }) + expect(input(el).value).toBe('14 Oct 2026') + }) + + it('refuses what is not a day, in words, keeping the value', async () => { + const el = await mount(TAG, { value: '2026-10-14' }) + const seen: string[] = [] + el.addEventListener('change', () => seen.push(el.value)) + await type(el, 'whenever') + expect(el.value).toBe('2026-10-14') + expect(seen).toEqual([]) + expect(input(el).getAttribute('aria-invalid')).toBe('true') + expect(el.textContent).toContain('Use a day like') + }) + + it('an empty box clears the value', async () => { + const el = await mount(TAG, { value: '2026-10-14' }) + await type(el, '') + expect(el.value).toBe('') + expect(input(el).value).toBe('') + }) + + it('an empty field says what to do, never mm/dd/yyyy', async () => { + const el = await mount(TAG) + expect(input(el).placeholder).toBe('Choose a date') + expect(el.innerHTML).not.toContain('mm/dd/yyyy') + }) + + it('is named for a screen reader', async () => { + const el = await mount(TAG, { label: 'As of' }) + expect(input(el).getAttribute('aria-label')).toBe('As of') + }) +}) + +describe('the calendar', () => { + it('opens on its button, marks today, Escape closes', async () => { + const el = await mount(TAG) + await openCalendar(el) + expect(q(el, '[role="dialog"]')).toBeTruthy() + const today = gridDays(el).find((b) => b.getAttribute('aria-current') === 'date') + expect(today).toBeTruthy() + await key(el, 'Escape') + expect(el.querySelector('[role="dialog"]')).toBeNull() + }) + + it('opens on the value\'s month and picks with Enter', async () => { + const el = await mount(TAG, { value: '2026-10-14' }) + await openCalendar(el) + expect(el.textContent).toContain('October 2026') + const focused = gridDays(el).find((b) => b.tabIndex === 0) + expect(focused?.textContent).toBe('14') + await key(el, 'Enter') + expect(el.value).toBe('2026-10-14') + expect(el.querySelector('[role="dialog"]')).toBeNull() + }) + + it('arrows move a day or a week, Home and End the week', async () => { + // Wednesday Oct 14, 2026; the en-US week starts Sunday. + const el = await mount(TAG, { value: '2026-10-14' }) + await openCalendar(el) + await key(el, 'ArrowRight') + await key(el, 'Enter') + expect(el.value).toBe('2026-10-15') + await openCalendar(el) + await key(el, 'ArrowUp') + await key(el, 'Enter') + expect(el.value).toBe('2026-10-08') + await openCalendar(el) + await key(el, 'ArrowDown') + await key(el, 'Enter') + expect(el.value).toBe('2026-10-15') + await openCalendar(el) + await key(el, 'Home') // the Sunday of the 15th's week + await key(el, 'Enter') + expect(el.value).toBe('2026-10-11') + await openCalendar(el) + await key(el, 'End') + await key(el, 'Enter') + expect(el.value).toBe('2026-10-17') + }) + + it('Page Up and Page Down move a month', async () => { + const el = await mount(TAG, { value: '2026-10-14' }) + await openCalendar(el) + await key(el, 'PageUp') + expect(el.textContent).toContain('September 2026') + await key(el, 'Enter') + expect(el.value).toBe('2026-09-14') + await openCalendar(el) + await key(el, 'PageDown') + await key(el, 'PageDown') + expect(el.textContent).toContain('November 2026') + }) + + it('the week starts as the locale starts it', async () => { + // Oct 2026 starts on a Thursday: four blanks under en-US, three under en-GB. + const el = await mount(TAG, { value: '2026-10-01' }) + await openCalendar(el) + let blanks = 0 + for (const c of Array.from(el.querySelectorAll('[role="grid"] > span'))) { + if (c.getAttribute('role') === 'gridcell') break + if (c.textContent?.trim() === '') blanks++ + } + expect(blanks).toBe(4) + await key(el, 'Escape') + setDealerLocale('en-GB') + const el2 = await mount(TAG, { value: '2026-10-01' }) + await openCalendar(el2) + let blanks2 = 0 + for (const c of Array.from(el2.querySelectorAll('[role="grid"] > span'))) { + if (c.getAttribute('role') === 'gridcell') break + if (c.textContent?.trim() === '') blanks2++ + } + expect(blanks2).toBe(3) + }) +}) + +describe('min and max', () => { + it('a typed day outside the bounds is refused', async () => { + const el = await mount(TAG, { min: '2026-10-10', max: '2026-10-20' }) + await type(el, 'Oct 1, 2026') + expect(el.value).toBe('') + expect(input(el).getAttribute('aria-invalid')).toBe('true') + await type(el, '2026-10-25') + expect(el.value).toBe('') + await type(el, '2026-10-15') + expect(el.value).toBe('2026-10-15') + }) + + it('grid days outside the bounds are disabled', async () => { + const el = await mount(TAG, { value: '2026-10-14', min: '2026-10-10', max: '2026-10-20' }) + await openCalendar(el) + const days = gridDays(el) + expect(days.find((b) => b.textContent === '9')?.disabled).toBe(true) + expect(days.find((b) => b.textContent === '21')?.disabled).toBe(true) + expect(days.find((b) => b.textContent === '14')?.disabled).toBe(false) + // Enter on the focus moved out of bounds does not pick. + await key(el, 'ArrowUp') + await key(el, 'ArrowUp') + await key(el, 'Enter') + expect(el.value).toBe('2026-10-14') + }) +}) diff --git a/web/apps/desk/src/components/ui/gable-date-field.ts b/web/apps/desk/src/components/ui/gable-date-field.ts new file mode 100644 index 000000000..cc410d427 --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-date-field.ts @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The app's own date field (G1 walk note N15): a text box that shows the day + * in the date module's style and accepts typing in it or as YYYY-MM-DD, plus + * a calendar in the app's own popover (a month grid whose week starts as the + * locale starts it; arrows move a day or a week, Page Up and Page Down a + * month, Home and End the week, Enter picks, Escape closes; today is marked; + * min and max are honoured). The value is always YYYY-MM-DD (or empty) for + * the API, and commits fire `input` and `change`. An empty field says what to + * do in words ("Choose a date"), never "mm/dd/yyyy". + * + * Light DOM, so Tailwind classes apply as in the neighbouring components. + */ +import { LitElement, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { icon } from '../../lib/icons.ts'; +import { Calendar, ChevronLeft, ChevronRight } from 'lucide'; +import { formatDay, parseDay, todayDay, weekStart, weekdayNames, monthName } from '../../lib/dates.ts'; + +let nextId = 0; + +@customElement('gable-date-field') +export class GableDateField extends LitElement { + createRenderRoot() { return this; } + + /** The day for the API: YYYY-MM-DD, or empty. */ + @property({ type: String }) value = ''; + /** Bounds, YYYY-MM-DD. */ + @property({ type: String }) min = ''; + @property({ type: String }) max = ''; + /** The accessible name of the text box. */ + @property({ type: String }) label = ''; + @property({ type: Boolean }) disabled = false; + + @state() private _text = ''; + @state() private _open = false; + @state() private _invalid = false; + @state() private _viewYear = 0; + @state() private _viewMonth = 0; + @state() private _focusDay = ''; + + private _id = `gable-date-field-${++nextId}`; + private _outside = (e: MouseEvent) => { + if (this._open && !this.contains(e.target as Node)) this._close(); + }; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener('mousedown', this._outside); + } + + disconnectedCallback() { + document.removeEventListener('mousedown', this._outside); + super.disconnectedCallback(); + } + + willUpdate(changed: Map) { + if (changed.has('value')) { + // An external set repaints the box; the box's own typing is left + // alone while the popover is closed over it. + const shown = formatDay(this.value); + if (this.value === '' || shown !== this.value) { + if (this._text !== shown && (this.value === '' || parseDay(this._text) !== this.value)) { + this._text = this.value ? shown : ''; + this._invalid = false; + } + } + } + } + + /** Commit a business day (or empty), repaint the box, fire the events. */ + private _commit(day: string) { + this.value = day; + this._text = day ? formatDay(day) : ''; + this._invalid = false; + this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + } + + private _inBounds(day: string): boolean { + if (!day) return false; + if (this.min && day < this.min) return false; + if (this.max && day > this.max) return false; + return true; + } + + private _onTextInput(e: Event) { + this._text = (e.target as HTMLInputElement).value; + this._invalid = false; + } + + private _onTextCommit() { + const t = this._text.trim(); + if (t === '') { + if (this.value !== '') this._commit(''); + this._invalid = false; + return; + } + const day = parseDay(t); + if (day && this._inBounds(day)) { + if (day !== this.value || this._invalid) this._commit(day); + else this._text = formatDay(day); + this._invalid = false; + } else { + this._invalid = true; + } + } + + private _onTextKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + this._onTextCommit(); + } else if (e.key === 'ArrowDown' && !this._open) { + e.preventDefault(); + this._openCalendar(); + } else if (e.key === 'Escape' && this._open) { + e.preventDefault(); + this._close(); + } + } + + private _openCalendar() { + if (this.disabled) return; + const base = this.value && this._inBounds(this.value) ? this.value : todayDay(); + const [y, m] = base.split('-').map(Number); + this._viewYear = y; + this._viewMonth = m; + this._focusDay = base; + this._open = true; + } + + private _close() { + this._open = false; + } + + private _pick(day: string) { + if (!this._inBounds(day)) return; + this._commit(day); + this._close(); + this.querySelector('input')?.focus(); + } + + /** Move the grid's focus by days or months; the view follows. */ + private _move(day: string, deltaDays: number, deltaMonths: number) { + const [y, m, d] = day.split('-').map(Number); + let date: Date; + if (deltaMonths !== 0) { + // A month step keeps the day of the month when it can (the 31st + // of a short month lands on its last day). + const target = new Date(y, m - 1 + deltaMonths, 1); + const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate(); + date = new Date(target.getFullYear(), target.getMonth(), Math.min(d, last)); + } else { + date = new Date(y, m - 1, d + deltaDays); + } + const next = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + this._focusDay = next; + this._viewYear = date.getFullYear(); + this._viewMonth = date.getMonth() + 1; + } + + private _onGridKeydown(e: KeyboardEvent) { + const day = this._focusDay || todayDay(); + const start = weekStart(); + const weekday = (new Date(Number(day.slice(0, 4)), Number(day.slice(5, 7)) - 1, Number(day.slice(8, 10))).getDay() - start + 7) % 7; + switch (e.key) { + case 'ArrowLeft': e.preventDefault(); this._move(day, -1, 0); break; + case 'ArrowRight': e.preventDefault(); this._move(day, 1, 0); break; + case 'ArrowUp': e.preventDefault(); this._move(day, -7, 0); break; + case 'ArrowDown': e.preventDefault(); this._move(day, 7, 0); break; + case 'PageUp': e.preventDefault(); this._move(day, 0, e.shiftKey ? -12 : -1); break; + case 'PageDown': e.preventDefault(); this._move(day, 0, e.shiftKey ? 12 : 1); break; + case 'Home': e.preventDefault(); this._move(day, -weekday, 0); break; + case 'End': e.preventDefault(); this._move(day, 6 - weekday, 0); break; + case 'Enter': + case ' ': + e.preventDefault(); + this._pick(day); + break; + case 'Escape': + e.preventDefault(); + this._close(); + this.querySelector('input')?.focus(); + break; + case 'Tab': + this._close(); + break; + } + } + + private _shiftMonth(delta: number) { + const d = new Date(this._viewYear, this._viewMonth - 1 + delta, 1); + this._viewYear = d.getFullYear(); + this._viewMonth = d.getMonth() + 1; + const day = Number(this._focusDay.slice(8, 10)); + const last = new Date(this._viewYear, this._viewMonth, 0).getDate(); + this._focusDay = `${this._viewYear}-${String(this._viewMonth).padStart(2, '0')}-${String(Math.min(day, last)).padStart(2, '0')}`; + } + + /** The visible month's days: leading blanks, then each day. */ + private _grid(): Array { + const start = weekStart(); + const first = new Date(this._viewYear, this._viewMonth - 1, 1); + const blanks = (first.getDay() - start + 7) % 7; + const days = new Date(this._viewYear, this._viewMonth, 0).getDate(); + const out: Array = Array.from({ length: blanks }, () => null); + for (let d = 1; d <= days; d++) { + out.push(`${this._viewYear}-${String(this._viewMonth).padStart(2, '0')}-${String(d).padStart(2, '0')}`); + } + return out; + } + + render() { + const inputClasses = `w-full bg-white/5 border rounded-lg px-3 py-2 pr-9 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-gable-green/50 ${this._invalid ? 'border-safety-red' : 'border-white/10'}`; + return html` +
+ + + ${this._invalid ? html` +

Use a day like ${formatDay(todayDay())} or ${todayDay()}

+ ` : nothing} + ${this._open ? this._renderPopover() : nothing} +
+ `; + } + + private _renderPopover() { + const names = weekdayNames(); + const today = todayDay(); + return html` + + `; + } + + /** After the grid re-renders, keep the focused day's button focused. */ + updated() { + if (this._open) { + const btn = this.querySelector(`#${this._id}-grid button[tabindex="0"]`) as HTMLElement | null; + if (btn && document.activeElement !== btn && this.contains(document.activeElement)) { + btn.focus(); + } + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gable-date-field': GableDateField; + } +} diff --git a/web/apps/desk/src/lib/credit-display.test.ts b/web/apps/desk/src/lib/credit-display.test.ts index bf28210ca..f90dbcf01 100644 --- a/web/apps/desk/src/lib/credit-display.test.ts +++ b/web/apps/desk/src/lib/credit-display.test.ts @@ -88,8 +88,11 @@ describe('credit money display', () => { }) describe('formatDay', () => { + // The one style of lib/dates.ts (N15): the dealer's locale, the month a + // short word; the business day still never shifts a day west of UTC (the + // module's own legs prove it under a western TZ). it('renders a business date without a time zone shift', () => { - expect(formatDay('2026-01-01')).toBe(new Date(2026, 0, 1).toLocaleDateString()) + expect(formatDay('2026-01-01')).toBe('Jan 1, 2026') expect(formatDay(null)).toBe('—') expect(formatDay('not a date')).toBe('not a date') }) diff --git a/web/apps/desk/src/lib/dates.test.ts b/web/apps/desk/src/lib/dates.test.ts new file mode 100644 index 000000000..824301fde --- /dev/null +++ b/web/apps/desk/src/lib/dates.test.ts @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The one date module's legs (G1 walk note N15): one style per locale (the + * dealer's, with the month as a short word), a business day that never shifts + * a day west of UTC, typed parsing of both accepted forms, the week the + * locale starts, and the once at start locale read (desk route for the desk, + * the portal's public config for the portal, en-US on any failure, quietly). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + initDates, + setDealerLocale, + getDealerLocale, + formatDay, + formatDate, + formatDateTime, + parseDay, + todayDay, + weekStart, + weekdayNames, + monthName, +} from './dates' +import { jsonResponse } from '../test/dom' + +const DAY = '2026-10-14' +const MOMENT = new Date(2026, 9, 14, 15, 4) + +beforeEach(() => setDealerLocale('en-US')) +afterEach(() => { + vi.unstubAllGlobals() + window.history.replaceState(null, '', '/') +}) + +describe('one style per locale', () => { + it('en-US', () => { + setDealerLocale('en-US') + expect(formatDay(DAY)).toBe('Oct 14, 2026') + expect(formatDate(MOMENT)).toBe('Oct 14, 2026') + expect(formatDateTime(MOMENT)).toBe('Oct 14, 2026, 3:04 PM') + }) + it('en-CA', () => { + setDealerLocale('en-CA') + expect(formatDay(DAY)).toBe('Oct 14, 2026') + expect(formatDate(MOMENT)).toBe('Oct 14, 2026') + expect(formatDateTime(MOMENT)).toBe('Oct 14, 2026, 3:04 p.m.') + }) + it('en-GB', () => { + setDealerLocale('en-GB') + expect(formatDay(DAY)).toBe('14 Oct 2026') + expect(formatDate(MOMENT)).toBe('14 Oct 2026') + expect(formatDateTime(MOMENT)).toBe('14 Oct 2026, 15:04') + }) + it('never a numeric month first or day first form', () => { + for (const tag of ['en-US', 'en-CA', 'en-GB', 'fr-CA']) { + setDealerLocale(tag) + const text = formatDay(DAY) + expect(text).not.toMatch(/\d{1,2}\/\d{1,2}/) + expect(text).toMatch(/[A-Za-z]{3}/) + } + }) +}) + +describe('formatDay is a business day', () => { + it('never shifts a day west of UTC', () => { + const tz = process.env.TZ + process.env.TZ = 'America/Los_Angeles' + try { + // The UTC parse of the same text lands on the day before out west; + // the business day is built from its parts instead. + expect(new Date('2026-01-15').getDate()).toBe(14) + expect(formatDay('2026-01-15')).toBe('Jan 15, 2026') + } finally { + process.env.TZ = tz + } + }) + it('empty and non date pass through as before', () => { + expect(formatDay(null)).toBe('—') + expect(formatDay(undefined)).toBe('—') + expect(formatDay('not a date')).toBe('not a date') + // A moment is not a business day; it passes through (formatDate reads it). + expect(formatDay('2026-10-14T09:00:00Z')).toBe('2026-10-14T09:00:00Z') + }) +}) + +describe('formatDate is a moment', () => { + it('reads ISO text and Dates; the unreadable come back as sent', () => { + expect(formatDate('2026-10-14T15:04:00')).toBe('Oct 14, 2026') + expect(formatDate(null)).toBe('—') + expect(formatDate('')).toBe('—') + expect(formatDate('junk')).toBe('junk') + expect(formatDateTime('2026-10-14T15:04:00')).toBe('Oct 14, 2026, 3:04 PM') + }) +}) + +describe('parseDay', () => { + it('takes YYYY-MM-DD', () => { + expect(parseDay('2026-10-14')).toBe(DAY) + expect(parseDay(' 2026-10-14 ')).toBe(DAY) + expect(parseDay('2026-02-30')).toBeNull() + expect(parseDay('2026-13-01')).toBeNull() + }) + it('takes the module style in the dealer locale', () => { + setDealerLocale('en-US') + expect(parseDay('Oct 14, 2026')).toBe(DAY) + expect(parseDay('October 14, 2026')).toBe(DAY) + expect(parseDay('14 Oct 2026')).toBe(DAY) + setDealerLocale('en-GB') + expect(parseDay('14 Oct 2026')).toBe(DAY) + expect(parseDay('Oct 14, 2026')).toBe(DAY) + }) + it('refuses what is not a day', () => { + expect(parseDay('')).toBeNull() + expect(parseDay('14 2026')).toBeNull() + expect(parseDay('Oct 14')).toBeNull() + expect(parseDay('Oct 2026 14 extra 9')).toBeNull() + expect(parseDay('Smarch 14, 2026')).toBeNull() + }) +}) + +describe('the week and the grid words', () => { + it('the week starts as the locale starts it', () => { + setDealerLocale('en-US') + expect(weekStart()).toBe(0) + setDealerLocale('en-GB') + expect(weekStart()).toBe(1) + }) + it('weekday names follow the week start', () => { + setDealerLocale('en-US') + expect(weekdayNames()).toEqual(['S', 'M', 'T', 'W', 'T', 'F', 'S']) + setDealerLocale('en-GB') + expect(weekdayNames()).toEqual(['M', 'T', 'W', 'T', 'F', 'S', 'S']) + }) + it('the grid heading names the month and year', () => { + expect(monthName(10, 2026)).toBe('October 2026') + }) + it('todayDay is the business day of now', () => { + expect(todayDay()).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) +}) + +describe('initDates', () => { + function stubLocale(body: unknown, ok = true) { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(ok ? jsonResponse(body) : Promise.resolve(new Response('no', { status: 500 })))), + ) + } + + it('reads the desk route for a desk page', async () => { + window.history.replaceState(null, '', '/erp/reports/ar-aging') + stubLocale({ locale: 'en-CA' }) + await initDates() + expect(getDealerLocale()).toBe('en-CA') + expect(vi.mocked(fetch).mock.calls[0][0]).toContain('/api/v1/dealer-profile/locale') + }) + + it('reads the portal config for a portal page', async () => { + window.history.replaceState(null, '', '/portal/orders') + stubLocale({ locale: 'en-GB' }) + await initDates() + expect(getDealerLocale()).toBe('en-GB') + expect(vi.mocked(fetch).mock.calls[0][0]).toContain('/api/portal/v1/config') + }) + + it('falls back to en-US on failure, quietly', async () => { + setDealerLocale('en-GB') + stubLocale(null, false) + await initDates() + expect(getDealerLocale()).toBe('en-GB') + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('down')))) + setDealerLocale('en-US') + await initDates() + expect(getDealerLocale()).toBe('en-US') + }) + + it('a tag Intl refuses leaves the default', async () => { + setDealerLocale('en-US') + stubLocale({ locale: 'not a locale' }) + await initDates() + expect(getDealerLocale()).toBe('en-US') + }) +}) diff --git a/web/apps/desk/src/lib/dates.ts b/web/apps/desk/src/lib/dates.ts new file mode 100644 index 000000000..8ec5e3be1 --- /dev/null +++ b/web/apps/desk/src/lib/dates.ts @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The one date module (G1 walk note N15). Every date shown to a person goes + * through here, in one style: the dealer's locale with the month as a short + * word ("Oct 14, 2026" in en-US, "14 Oct 2026" in en-GB), so no reader can + * mistake a month first form for a day first one. The dealer's locale is read + * once at start (initDates, before the pages render): the desk route for the + * desk's pages, the portal's public config for the portal's, and on any + * failure en-US, quietly. + * + * formatDay is a business day (YYYY-MM-DD, no time zone) built from its + * parts, so a machine west of UTC never shows the day before. formatDate and + * formatDateTime are a moment, in the person's own time zone; the time form + * adds hour and minute. + * + * The guard (dates.guard.test.ts) keeps every other file off the native + * inputs and the locale formatters. + */ + +import { fetchWithAuth } from '../services/fetchClient.ts'; + +const API_URL = import.meta.env.VITE_API_URL || ''; + +const DEFAULT_LOCALE = 'en-US'; + +let dealerLocale = DEFAULT_LOCALE; + +/** The locale the formats run in. */ +export function getDealerLocale(): string { + return dealerLocale; +} + +/** A tag Intl refuses (or an empty one) never leaves the default. */ +function acceptable(tag: unknown): tag is string { + if (typeof tag !== 'string' || tag === '') return false; + try { + new Intl.DateTimeFormat(tag); + return true; + } catch { + return false; + } +} + +/** + * Read the dealer's locale once, before the pages render. The portal's pages + * take the public config; every other page takes the desk route. Any failure + * leaves the en-US default, quietly. + */ +export async function initDates(): Promise { + const portal = window.location.pathname.startsWith('/portal'); + const url = portal + ? `${API_URL}/api/portal/v1/config` + : `${API_URL}/api/v1/dealer-profile/locale`; + try { + const res = await fetchWithAuth(url, portal ? { credentials: 'include' } : {}); + if (!res.ok) return; + const body: unknown = await res.json(); + const tag = (body as { locale?: unknown } | null)?.locale; + if (acceptable(tag)) dealerLocale = tag; + } catch { + // quiet: the en-US default stands + } +} + +/** Tests drive the locale through the same door initDates uses. */ +export function setDealerLocale(tag: string): void { + dealerLocale = acceptable(tag) ? tag : DEFAULT_LOCALE; +} + +function dayFormatter(): Intl.DateTimeFormat { + return new Intl.DateTimeFormat(dealerLocale, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function momentFormatter(): Intl.DateTimeFormat { + return new Intl.DateTimeFormat(dealerLocale, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} + +// A business date (YYYY-MM-DD, no time zone) rendered in the dealer's locale. +// Built from its parts so a west-of-UTC machine never shows the day before; +// anything that is not a plain date is returned as it came. +export function formatDay(day: string | null | undefined): string { + if (!day) return '—'; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!m) return day; + return dayFormatter().format(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); +} + +// A moment (a Date, or the ISO text the API sends) rendered in the dealer's +// locale, in the person's own time zone. An unreadable moment is returned as +// it came. +export function formatDate(value: string | number | Date | null | undefined): string { + if (value === null || value === undefined || value === '') return '—'; + const d = value instanceof Date ? value : new Date(value); + if (isNaN(d.getTime())) return String(value); + return dayFormatter().format(d); +} + +// A moment with its time, hour and minute added to the date's style. +export function formatDateTime(value: string | number | Date | null | undefined): string { + if (value === null || value === undefined || value === '') return '—'; + const d = value instanceof Date ? value : new Date(value); + if (isNaN(d.getTime())) return String(value); + return momentFormatter().format(d); +} + +// A moment's clock time alone, hour and minute in the person's own time zone +// (an ETA, a "checked at"): the same two units formatDateTime adds. +export function formatTime(value: string | number | Date | null | undefined): string { + if (value === null || value === undefined || value === '') return '—'; + const d = value instanceof Date ? value : new Date(value); + if (isNaN(d.getTime())) return String(value); + return new Intl.DateTimeFormat(dealerLocale, { hour: 'numeric', minute: '2-digit' }).format(d); +} + +/** Today as a business day, in the person's own time zone. */ +export function todayDay(): string { + const n = new Date(); + return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`; +} + +/** A business day n days on from another (or from today), as YYYY-MM-DD. */ +export function addDays(day: string, n: number): string { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + const d = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(); + d.setDate(d.getDate() + n); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** + * Parse a typed day: the module's own style (the month as a short word, day + * and four digit year in the locale's order) or YYYY-MM-DD. Answers the + * business day, or null when the text is not a day. + */ +export function parseDay(text: string): string | null { + const t = text.trim(); + const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t); + if (iso) { + return validDay(Number(iso[1]), Number(iso[2]), Number(iso[3])); + } + // The month as a word: the locale's short or long month names, matched + // between non letters (a short name that prefixes a long one, or a name + // that ends in a full stop, still matches). + let month = 0; + let rest = t; + const names = monthNamesForParsing(); + for (let i = 0; i < 12 && !month; i++) { + for (const name of names[i]) { + const re = new RegExp(`(^|[^\\p{L}])${escapeRe(name)}(?=[^\\p{L}]|$)`, 'iu'); + if (re.test(rest)) { + month = i + 1; + rest = rest.replace(re, '$1 '); + break; + } + } + } + if (!month) return null; + const nums = rest.match(/\d+/g); + if (!nums || nums.length !== 2) return null; + const [a, b] = nums.map(Number); + const year = a > 31 ? a : b > 31 ? b : -1; + const day = a > 31 ? b : b > 31 ? a : -1; + if (year < 1000 || day < 1) return null; + return validDay(year, month, day); +} + +function validDay(year: number, month: number, day: number): string | null { + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + const d = new Date(year, month - 1, day); + if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** The locale's short month names, January first. */ +export function monthNames(): string[] { + const f = new Intl.DateTimeFormat(dealerLocale, { month: 'short' }); + const out: string[] = []; + for (let i = 0; i < 12; i++) out.push(f.format(new Date(2026, i, 15))); + return out; +} + +/** Each month's accepted typed forms: the short and the long name. */ +function monthNamesForParsing(): string[][] { + const short = new Intl.DateTimeFormat(dealerLocale, { month: 'short' }); + const long = new Intl.DateTimeFormat(dealerLocale, { month: 'long' }); + const out: string[][] = []; + for (let i = 0; i < 12; i++) { + const d = new Date(2026, i, 15); + out.push([short.format(d), long.format(d)]); + } + return out; +} + +/** The locale's long month name for a month grid's heading (1 is January). */ +export function monthName(month: number, year: number): string { + return new Intl.DateTimeFormat(dealerLocale, { month: 'long', year: 'numeric' }).format(new Date(year, month - 1, 15)); +} + +/** + * The day the locale starts its week on, in Date#getDay numbering (0 is + * Sunday): the grid's first column. + */ +export function weekStart(): number { + try { + const loc = new Intl.Locale(dealerLocale) as unknown as { + getWeekInfo?: () => { firstDay: number }; + weekInfo?: { firstDay: number }; + }; + // The week info came as the weekInfo getter first and the + // getWeekInfo() method later; read whichever the runtime has. Its + // firstDay is 1 for Monday through 7 for Sunday. + const info = loc.getWeekInfo ? loc.getWeekInfo() : loc.weekInfo; + if (info && info.firstDay >= 1 && info.firstDay <= 7) return info.firstDay % 7; + } catch { + // fall through to Sunday + } + return 0; +} + +/** The locale's weekday names, narrow, starting on weekStart(). */ +export function weekdayNames(): string[] { + const f = new Intl.DateTimeFormat(dealerLocale, { weekday: 'narrow' }); + const start = weekStart(); + const out: string[] = []; + // 2026-01-04 was a Sunday. + for (let i = 0; i < 7; i++) out.push(f.format(new Date(2026, 0, 4 + ((start + i) % 7)))); + return out; +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/web/apps/desk/src/lib/utils.ts b/web/apps/desk/src/lib/utils.ts index 02d8033f3..19a492c51 100644 --- a/web/apps/desk/src/lib/utils.ts +++ b/web/apps/desk/src/lib/utils.ts @@ -47,15 +47,10 @@ export function formatPrice4(tenThousandths: number | null | undefined): string return `${negative && abs !== 0 ? '-' : ''}$${wholeText}.${frac}`; } -// A business date (YYYY-MM-DD, no time zone) rendered in the user's locale. Built -// from its parts so a west-of-UTC machine never shows the day before; anything -// that is not a plain date is returned as it came. -export function formatDay(day: string | null | undefined): string { - if (!day) return '—'; - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); - if (!m) return day; - return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).toLocaleDateString(); -} +// formatDay MOVED to ./dates.ts (the one date module, G1 walk note N15); it +// is re-exported here so the existing `import { formatDay } from './utils'` +// callers keep working. +export { formatDay } from './dates.ts'; // Money rendering for endpoints that already answer in float dollars (the // purchase order wire). Sits beside `formatCents` rather than replacing it: diff --git a/web/apps/desk/src/main.ts b/web/apps/desk/src/main.ts index e4a0550c0..38761b396 100644 --- a/web/apps/desk/src/main.ts +++ b/web/apps/desk/src/main.ts @@ -5,6 +5,7 @@ import './index.css'; import { router } from './lib/router.ts'; import { routes } from './routes.ts'; import { branchContext } from './services/BranchContext.ts'; +import { initDates } from './lib/dates.ts'; import './app.ts'; // Mount the app into #root (replacing the loading spinner from index.html) @@ -13,11 +14,13 @@ if (root) { root.innerHTML = ''; } -// Load the user's branch grants and pick a default BEFORE the router -// dispatches its first route. This guarantees X-Branch-Id is set on the -// page's initial data fetches. We swallow errors here so the app still -// boots when the user is unauthenticated (login screen) or the API is -// unreachable — pages will surface their own errors. -branchContext.init().finally(() => { +// Load the user's branch grants and pick a default, and read the dealer's +// locale for the date formats, BEFORE the router dispatches its first route. +// This guarantees X-Branch-Id is set on the page's initial data fetches and +// the first render already shows dates in the dealer's locale. Errors are +// swallowed so the app still boots when the user is unauthenticated (login +// screen) or the API is unreachable — pages will surface their own errors, +// and the dates fall back to en-US. +Promise.allSettled([branchContext.init(), initDates()]).then(() => { router.init(routes); }); diff --git a/web/apps/desk/src/pages/DailyTill.ts b/web/apps/desk/src/pages/DailyTill.ts index b8523340d..dc9a0577b 100644 --- a/web/apps/desk/src/pages/DailyTill.ts +++ b/web/apps/desk/src/pages/DailyTill.ts @@ -5,6 +5,7 @@ import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../lib/icons.ts'; import { ToastService } from '../lib/toast-service.ts'; +import { formatDay } from '../lib/dates.ts'; import { ReportingService } from '../services/ReportingService.ts'; import type { DailyTillReport, SalesSummaryReport } from '../types/reporting.ts'; import { DollarSign, CreditCard, BarChart2 } from 'lucide'; @@ -60,7 +61,7 @@ export class GableDailyTill extends LitElement {

- ${icon(DollarSign, 24)} Daily Till (${till.date}) + ${icon(DollarSign, 24)} Daily Till (${formatDay(till.date)})

$${till.total_collected.toFixed(2)} diff --git a/web/apps/desk/src/pages/Dashboard.ts b/web/apps/desk/src/pages/Dashboard.ts index 6d91f33df..d274422e3 100644 --- a/web/apps/desk/src/pages/Dashboard.ts +++ b/web/apps/desk/src/pages/Dashboard.ts @@ -8,6 +8,7 @@ import { RefreshCw, DollarSign, ShoppingCart, Truck, CreditCard, Calendar, Alert import { DashboardService } from '../services/DashboardService.ts'; import { ToastService } from '../lib/toast-service.ts'; import { onBranchChanged } from '../lib/branch-listener.ts'; +import { formatDay, formatTime, todayDay } from '../lib/dates.ts'; import type { DashboardSummary, InventoryAlert, @@ -100,7 +101,7 @@ export class GableDashboard extends LitElement { } render() { - const currentDate = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + const currentDate = formatDay(todayDay()); const hour = new Date().getHours(); const timeGreeting = hour < 12 ? 'Good Morning' : hour < 17 ? 'Good Afternoon' : 'Good Evening'; @@ -127,7 +128,7 @@ export class GableDashboard extends LitElement {
diff --git a/web/apps/desk/src/pages/logistics/FleetManagement.ts b/web/apps/desk/src/pages/logistics/FleetManagement.ts index 1d68ecdb3..88364074c 100644 --- a/web/apps/desk/src/pages/logistics/FleetManagement.ts +++ b/web/apps/desk/src/pages/logistics/FleetManagement.ts @@ -7,6 +7,9 @@ import { icon } from '../../lib/icons.ts'; import { Truck, Users, Plus, Pencil, Trash2, AlertTriangle, RefreshCw, X, Upload } from 'lucide'; import { deliveryService } from '../../services/deliveryService'; import { ToastService } from '../../lib/toast-service.ts'; +import { formatDay } from '../../lib/dates.ts'; +import '../../components/ui/gable-date-field.ts'; +import type { GableDateField } from '../../components/ui/gable-date-field.ts'; import type { Vehicle, Driver, CreateVehicleRequest, UpdateVehicleRequest, CreateDriverRequest, UpdateDriverRequest, VehicleType, DriverStatus } from '../../types/delivery'; type Tab = 'vehicles' | 'drivers'; @@ -49,9 +52,9 @@ function dateBadgeClass(level: 'expired' | 'warning' | null): string { return 'text-zinc-300'; } +// A vehicle or driver document day, in the one style every page shares. function formatDate(d?: string | null): string { - if (!d) return '\u2014'; - return new Date(d).toLocaleDateString(); + return formatDay(d); } @customElement('gable-fleet-management') @@ -650,7 +653,7 @@ export class FleetManagement extends LitElement { return html`
- onChange((e.target as HTMLInputElement).value)} class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-gable-green/50" /> + onChange((e.target as GableDateField).value)}>
`; } diff --git a/web/apps/desk/src/pages/orders/OrderDetail.ts b/web/apps/desk/src/pages/orders/OrderDetail.ts index efa7c160a..3262a8a90 100644 --- a/web/apps/desk/src/pages/orders/OrderDetail.ts +++ b/web/apps/desk/src/pages/orders/OrderDetail.ts @@ -4,6 +4,7 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; +import { formatDateTime } from '../../lib/dates.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { OrderService } from '../../services/OrderService.ts'; import { InvoiceService } from '../../services/InvoiceService.ts'; @@ -221,7 +222,7 @@ export class GableOrderDetail extends LitElement {

- Created on ${new Date(order.created_at).toLocaleString()} + Created on ${formatDateTime(order.created_at)} · ${order.currency} · ${order.delivery_type === 'pickup' ? 'Pickup' : 'Delivery'} ${order.tax_rate_percent !== null ? html` · tax ${order.tax_rate_percent}%` : html` · tax by provider`} diff --git a/web/apps/desk/src/pages/orders/OrderList.ts b/web/apps/desk/src/pages/orders/OrderList.ts index bb65b1d75..1be0272dc 100644 --- a/web/apps/desk/src/pages/orders/OrderList.ts +++ b/web/apps/desk/src/pages/orders/OrderList.ts @@ -10,6 +10,7 @@ import { OrderService } from '../../services/OrderService.ts'; import { type OrderSummary, type OrderStatus, formatOrderStatus, getStatusColor } from '../../types/order.ts'; import { onBranchChanged } from '../../lib/branch-listener.ts'; import { formatCents } from '../../lib/utils.ts'; +import { formatDate } from '../../lib/dates.ts'; const STATUS_FILTERS: (OrderStatus | 'all')[] = ['all', 'draft', 'on_hold', 'confirmed', 'backordered', 'fulfilled', 'cancelled']; @@ -150,7 +151,7 @@ export class GableOrderList extends LitElement { ${order.hold_reason ? html`${order.hold_reason === 'credit_limit' ? 'credit hold' : 'held'}` : ''} ${formatCents(order.total_cents)} - ${new Date(order.created_at).toLocaleDateString()} + ${formatDate(order.created_at)} ${icon(ArrowRight, 16, 'text-zinc-500')} `)} diff --git a/web/apps/desk/src/pages/portal/PortalDashboard.ts b/web/apps/desk/src/pages/portal/PortalDashboard.ts index 76a06736d..2c3f7b453 100644 --- a/web/apps/desk/src/pages/portal/PortalDashboard.ts +++ b/web/apps/desk/src/pages/portal/PortalDashboard.ts @@ -6,6 +6,7 @@ import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { DollarSign, CreditCard, AlertTriangle, ShoppingCart, ArrowRight, RefreshCw } from 'lucide'; import { PortalService } from '../../services/PortalService'; +import { formatDate } from '../../lib/dates.ts'; import type { PortalDashboard as DashboardData } from '../../types/portal'; const formatCurrency = (val: number): string => @@ -145,7 +146,7 @@ export class PortalDashboard extends LitElement { ${order.id.substring(0, 8).toUpperCase()}

- ${new Date(order.created_at).toLocaleDateString()} · ${order.lines.length} item${order.lines.length !== 1 ? 's' : ''} + ${formatDate(order.created_at)} · ${order.lines.length} item${order.lines.length !== 1 ? 's' : ''}
diff --git a/web/apps/desk/src/pages/portal/PortalDeliveries.ts b/web/apps/desk/src/pages/portal/PortalDeliveries.ts index 7fc8892df..116f66b24 100644 --- a/web/apps/desk/src/pages/portal/PortalDeliveries.ts +++ b/web/apps/desk/src/pages/portal/PortalDeliveries.ts @@ -6,6 +6,7 @@ import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { Truck, Camera, RefreshCw, AlertTriangle, X, User, Clock, Phone, MapPin, FileText, ChevronDown, ChevronUp } from 'lucide'; import { PortalService } from '../../services/PortalService'; +import { formatDay, formatDate, formatDateTime, formatTime } from '../../lib/dates.ts'; import type { PortalDelivery } from '../../types/portal'; type FilterTab = 'all' | 'active' | 'upcoming' | 'completed'; @@ -20,7 +21,7 @@ function etaLabel(eta: string | null): string | null { if (diffMs <= 0) return 'Arriving now'; const diffMin = Math.round(diffMs / 60000); if (diffMin < 120) return `Arriving in ${diffMin} min`; - return `ETA ${arrival.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + return `ETA ${formatTime(arrival)}`; } const deliveryStatusColor = (status: string): { fg: string; bg: string } => { @@ -159,8 +160,8 @@ export class PortalDeliveries extends LitElement { Order ${(del.order_number ?? del.order_id).substring(0, 8).toUpperCase()} ${del.scheduled_date - ? html`${icon(Clock, 10)} ${new Date(del.scheduled_date).toLocaleDateString()}` - : html`${new Date(del.created_at).toLocaleDateString()}` + ? html`${icon(Clock, 10)} ${formatDay(del.scheduled_date.slice(0, 10))}` + : html`${formatDate(del.created_at)}` }
@@ -238,7 +239,7 @@ export class PortalDeliveries extends LitElement { ${del.pod_timestamp ? html`
${icon(Clock, 12)} - ${new Date(del.pod_timestamp).toLocaleString()} + ${formatDateTime(del.pod_timestamp)}
` : nothing} ${del.pod_proof_url ? html` diff --git a/web/apps/desk/src/pages/portal/PortalInvoices.ts b/web/apps/desk/src/pages/portal/PortalInvoices.ts index 1ab3041e7..9ad720977 100644 --- a/web/apps/desk/src/pages/portal/PortalInvoices.ts +++ b/web/apps/desk/src/pages/portal/PortalInvoices.ts @@ -6,6 +6,7 @@ import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { FileText, Download, RefreshCw, AlertTriangle } from 'lucide'; import { PortalService } from '../../services/PortalService'; +import { formatDay, formatDate } from '../../lib/dates.ts'; import type { PortalInvoice } from '../../types/portal'; // Portal invoice amounts arrive as float64 DOLLARS (see backend portal/model.go), @@ -61,10 +62,10 @@ export class PortalInvoices extends LitElement { private _handleDownloadPDF(invoice: PortalInvoice) { const content = [ `INVOICE ${invoice.number}`, - `Date: ${new Date(invoice.created_at).toLocaleDateString()}`, + `Date: ${formatDate(invoice.created_at)}`, `Status: ${portalStatusLabel(invoice.status)}${invoice.is_overdue ? ' (overdue)' : ''}`, `Payment Terms: ${invoice.payment_terms}`, - invoice.due_date ? `Due Date: ${new Date(invoice.due_date).toLocaleDateString()}` : '', + invoice.due_date ? `Due Date: ${formatDay(invoice.due_date.slice(0, 10))}` : '', '', `Subtotal: ${formatCurrency(invoice.subtotal)}`, `Tax: ${formatCurrency(invoice.tax_amount)}`, @@ -137,7 +138,7 @@ export class PortalInvoices extends LitElement { ${inv.number}
- ${new Date(inv.created_at).toLocaleDateString()}${inv.due_date ? html` · Due ${new Date(inv.due_date).toLocaleDateString()}` : ''} + ${formatDate(inv.created_at)}${inv.due_date ? html` · Due ${formatDay(inv.due_date.slice(0, 10))}` : ''}
diff --git a/web/apps/desk/src/pages/portal/PortalOrders.ts b/web/apps/desk/src/pages/portal/PortalOrders.ts index 6e0e1c2bf..fbb2aa8cb 100644 --- a/web/apps/desk/src/pages/portal/PortalOrders.ts +++ b/web/apps/desk/src/pages/portal/PortalOrders.ts @@ -5,6 +5,7 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { ToastService } from '../../lib/toast-service.ts'; +import { formatDate } from '../../lib/dates.ts'; import { ShoppingCart, RefreshCw, AlertTriangle, ChevronDown, ChevronUp } from 'lucide'; import { PortalService } from '../../services/PortalService'; import type { PortalOrder } from '../../types/portal'; @@ -131,7 +132,7 @@ export class PortalOrders extends LitElement { ${order.id.substring(0, 8).toUpperCase()}
- ${new Date(order.created_at).toLocaleDateString()} · ${order.lines.length} item${order.lines.length !== 1 ? 's' : ''} + ${formatDate(order.created_at)} · ${order.lines.length} item${order.lines.length !== 1 ? 's' : ''}
diff --git a/web/apps/desk/src/pages/portal/PortalTeam.ts b/web/apps/desk/src/pages/portal/PortalTeam.ts index f967886cc..ca9f7d141 100644 --- a/web/apps/desk/src/pages/portal/PortalTeam.ts +++ b/web/apps/desk/src/pages/portal/PortalTeam.ts @@ -4,6 +4,7 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; +import { formatDate } from '../../lib/dates.ts'; import { router } from '../../lib/router.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { Users, UserPlus, RefreshCw, AlertTriangle, Shield, CheckCircle2, XCircle, Clock } from 'lucide'; @@ -182,7 +183,7 @@ export class PortalTeam extends LitElement {
${invite.email}
- Invited on ${new Date(invite.created_at).toLocaleDateString()} · Expires ${new Date(invite.expires_at).toLocaleDateString()} + Invited on ${formatDate(invite.created_at)} · Expires ${formatDate(invite.expires_at)}
diff --git a/web/apps/desk/src/pages/projects/ProjectDashboard.ts b/web/apps/desk/src/pages/projects/ProjectDashboard.ts index 745f85edc..ce6b08ecf 100644 --- a/web/apps/desk/src/pages/projects/ProjectDashboard.ts +++ b/web/apps/desk/src/pages/projects/ProjectDashboard.ts @@ -4,6 +4,7 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; +import { formatDate } from '../../lib/dates.ts'; import { ArrowLeft, AlertTriangle, ShoppingCart, Truck, FileText, Calendar } from 'lucide'; import { ProjectService } from '../../services/ProjectService.ts'; import { ApiError, apiErrorMessage } from '../../services/apiError.ts'; @@ -92,7 +93,7 @@ export class ProjectDashboard extends LitElement {
- ${icon(Calendar, 12)} ${new Date(item.created_at).toLocaleDateString()} + ${icon(Calendar, 12)} ${formatDate(item.created_at)}
diff --git a/web/apps/desk/src/pages/projects/ProjectList.ts b/web/apps/desk/src/pages/projects/ProjectList.ts index 459606f9b..4ae0ab2fc 100644 --- a/web/apps/desk/src/pages/projects/ProjectList.ts +++ b/web/apps/desk/src/pages/projects/ProjectList.ts @@ -8,7 +8,7 @@ import { FolderGit2, Plus, ArrowRight, RefreshCw, AlertTriangle, Briefcase } fro import { ProjectService } from '../../services/ProjectService.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { router } from '../../lib/router.ts'; -import { format } from 'date-fns'; +import { formatDate } from '../../lib/dates.ts'; import type { Project } from '../../types/project.ts'; @customElement('gable-project-list') @@ -164,7 +164,7 @@ export class ProjectList extends LitElement {

${project.name}

- Created ${format(new Date(project.created_at), 'MMM d, yyyy')} + Created ${formatDate(project.created_at)} · - ${new Date(po.created_at).toLocaleDateString()} + ${formatDate(po.created_at)} `;})} diff --git a/web/apps/desk/src/pages/quotes/QuoteAnalytics.ts b/web/apps/desk/src/pages/quotes/QuoteAnalytics.ts index f7f0e32cb..0d4d59939 100644 --- a/web/apps/desk/src/pages/quotes/QuoteAnalytics.ts +++ b/web/apps/desk/src/pages/quotes/QuoteAnalytics.ts @@ -10,6 +10,7 @@ import { ArrowLeft, TrendingUp, Target, Clock, ListChecks, BarChart3, Percent } import { QuoteService } from '../../services/QuoteService.ts'; import type { QuoteAnalytics as QuoteAnalyticsType } from '../../types/quote.ts'; import { formatCents } from '../../lib/utils.ts'; +import { formatDay } from '../../lib/dates.ts'; @customElement('gable-quote-analytics') export class GableQuoteAnalytics extends LitElement { @@ -191,7 +192,7 @@ export class GableQuoteAnalytics extends LitElement {

30-Day Quote Trend

${analytics.trend_data.map(day => html` -
+
- ${analytics.trend_data[0]?.date} + ${analytics.trend_data[0]?.date ? formatDay(analytics.trend_data[0].date) : ''}
Created Accepted
- ${analytics.trend_data[analytics.trend_data.length - 1]?.date} + ${analytics.trend_data[analytics.trend_data.length - 1]?.date ? formatDay(analytics.trend_data[analytics.trend_data.length - 1].date) : ''}
` : nothing} diff --git a/web/apps/desk/src/pages/quotes/QuoteDetail.ts b/web/apps/desk/src/pages/quotes/QuoteDetail.ts index 99d145e37..052c8554b 100644 --- a/web/apps/desk/src/pages/quotes/QuoteDetail.ts +++ b/web/apps/desk/src/pages/quotes/QuoteDetail.ts @@ -11,6 +11,7 @@ import { QuoteService, QuoteApiError, quoteErrorMessage } from '../../services/Q import type { Quote, QuoteStatus, ParseMapItem } from '../../types/quote.ts'; import { formatCents, formatPrice4 } from '../../lib/utils.ts'; import { extensionCents, dollarsToCents, floatDollarsToTenThousandths } from '../../lib/money.ts'; +import { formatDate, formatDateTime } from '../../lib/dates.ts'; import '../../components/quotes/exposure-banner.ts'; /** How much of a text list the Original Upload tab shows. */ @@ -137,7 +138,7 @@ export class GableQuoteDetail extends LitElement {
${label} - ${new Date(date).toLocaleString()} + ${formatDateTime(date)}
`; } @@ -515,7 +516,7 @@ export class GableQuoteDetail extends LitElement { ` : nothing}

- ${quote.customer_name || quote.customer_id.slice(0, 8)} · Created ${new Date(quote.created_at).toLocaleDateString()} + ${quote.customer_name || quote.customer_id.slice(0, 8)} · Created ${formatDate(quote.created_at)}

diff --git a/web/apps/desk/src/pages/quotes/QuoteList.ts b/web/apps/desk/src/pages/quotes/QuoteList.ts index 48ca64bb0..c94c9fdf3 100644 --- a/web/apps/desk/src/pages/quotes/QuoteList.ts +++ b/web/apps/desk/src/pages/quotes/QuoteList.ts @@ -11,6 +11,7 @@ import { QuoteService, QuoteApiError, quoteErrorMessage } from '../../services/Q import type { QuoteSummary, QuoteStatus } from '../../types/quote.ts'; import { formatCents } from '../../lib/utils.ts'; import { onBranchChanged } from '../../lib/branch-listener.ts'; +import { formatDate } from '../../lib/dates.ts'; @customElement('gable-quote-view-tabs') export class GableQuoteViewTabs extends LitElement { @@ -237,7 +238,7 @@ export class GableQuoteList extends LitElement { return html` router.navigate(`/quotes/${quote.id}`)}> ${quote.number} - ${new Date(quote.created_at).toLocaleDateString()} + ${formatDate(quote.created_at)} ${quote.customer_name || quote.customer_id.slice(0, 8)} ${quote.source === 'ai' ? html` diff --git a/web/apps/desk/src/pages/reports/ARAgingReport.ts b/web/apps/desk/src/pages/reports/ARAgingReport.ts index 25f826957..0ead618c1 100644 --- a/web/apps/desk/src/pages/reports/ARAgingReport.ts +++ b/web/apps/desk/src/pages/reports/ARAgingReport.ts @@ -7,7 +7,10 @@ import { icon } from '../../lib/icons.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { WalkTruncatedError } from '../../lib/cursorWalk'; import { formatCents } from '../../lib/utils.ts'; +import { formatDay } from '../../lib/dates.ts'; import { onBranchChanged } from '../../lib/branch-listener.ts'; +import '../../components/ui/gable-date-field.ts'; +import type { GableDateField } from '../../components/ui/gable-date-field.ts'; import { ArService } from '../../services/ArService.ts'; import type { ArAgingItem, ArAgingTotal, AgingGroupBy } from '../../types/account.ts'; import { DollarSign } from 'lucide'; @@ -80,8 +83,8 @@ export class ARAgingReportPage extends LitElement { this._load(); } - private _setAsOf(e: InputEvent) { - this.asOf = (e.target as HTMLInputElement).value; + private _setAsOf(e: Event) { + this.asOf = (e.target as GableDateField).value; this._load(); } @@ -116,7 +119,7 @@ export class ARAgingReportPage extends LitElement { AR Aging Report

- Accounts receivable aging as of ${this.asOf || 'today'}, never adding two currencies + Accounts receivable aging as of ${this.asOf ? formatDay(this.asOf) : 'today'}, never adding two currencies

@@ -141,8 +144,7 @@ export class ARAgingReportPage extends LitElement {
diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts index bedb5e0d1..a42e67abc 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts @@ -6,9 +6,12 @@ import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { formatCents } from '../../lib/utils.ts'; +import { formatDay } from '../../lib/dates.ts'; import { ArService } from '../../services/ArService.ts'; import { CustomerService } from '../../services/CustomerService.ts'; import { onBranchChanged } from '../../lib/branch-listener.ts'; +import '../../components/ui/gable-date-field.ts'; +import type { GableDateField } from '../../components/ui/gable-date-field.ts'; import { LedgerOutsideYardError, ledgerYardNote } from '../../lib/ledgerYards.ts'; import type { ArStatement, ArStatementCurrency } from '../../types/account.ts'; import type { Customer } from '../../types/customer.ts'; @@ -204,21 +207,19 @@ export class CustomerStatementPage extends LitElement {
- this.startDate = (e.target as HTMLInputElement).value} - class="bg-black/20 border border-white/10 rounded px-3 py-2 text-white focus:border-[#00FFA3] outline-none" - /> + @change=${(e: Event) => this.startDate = (e.target as GableDateField).value} + >
- this.endDate = (e.target as HTMLInputElement).value} - class="bg-black/20 border border-white/10 rounded px-3 py-2 text-white focus:border-[#00FFA3] outline-none" - /> + @change=${(e: Event) => this.endDate = (e.target as GableDateField).value} + >