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..5f6236ca9 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 ( @@ -14,9 +15,11 @@ import ( "image" "image/jpeg" "image/png" + "regexp" "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 +40,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 +54,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 +64,7 @@ type Input struct { Email string Website string RemitTo string + Locale string } // Logo is the stored image. @@ -66,6 +73,43 @@ type Logo struct { ContentType string } +// MaxLocaleTagLength caps a locale tag (BCP 47's own suggestion is 35). +const MaxLocaleTagLength = 35 + +// localeShape is exactly the column's check (migration 119), so a tag the +// write accepts never fails the database: letters and hyphens, no singleton +// subtags, no underscore. +var localeShape = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`) + +// CheckLocale accepts a BCP 47 tag the date formatter and the column both +// take ("en-US", "en-CA", "fr-CA") and refuses anything else in words. The +// tag must hold the column's shape, parse, and be its own canonical form up +// to case (an "en_US" or an "i-klingon" is another tag misspelled); the root +// tag and the local use block (qaa through qtz) are refused: no person means +// them. +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 !localeShape.MatchString(tag) { + return fmt.Errorf("the locale %q is not a language tag the date formatter accepts (letters and hyphens, such as en-US)", tag) + } + t, err := language.Parse(tag) + if 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) + } + if !strings.EqualFold(t.String(), tag) || t.String() == "und" { + return fmt.Errorf("the locale %q is not a language tag the date formatter accepts (a BCP 47 tag such as en-US)", tag) + } + if base, _ := t.Base(); base.String() >= "qaa" && base.String() <= "qtz" { + return fmt.Errorf("the locale %q is reserved for local use, not a language tag the date formatter accepts", 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..ab554b90d 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,60 @@ 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 tag the column's shape check refuses (the likeliest typo: an + // underscore) must be refused in words, never answered 500. + for _, bad := range []string{"en_US", "i-klingon", "en-u-ca-gregory", "en-CA-x-yard", "root", "und", "qaa", "x-whatever"} { + r = f.put(strings.Replace(withLocale, `"en-CA"`, `"`+bad+`"`, 1), "If-Match", `"`+strconv.FormatInt(f.revision(), 10)+`"`) + if r.status != http.StatusBadRequest { + t.Errorf("locale %q: %d %s, want 400 in words", bad, r.status, r.raw) + } + } + + // 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/apps/desk/package.json b/web/apps/desk/package.json index 7b4497052..aab8f3002 100644 --- a/web/apps/desk/package.json +++ b/web/apps/desk/package.json @@ -22,7 +22,6 @@ "@gable/design-system": "*", "chart.js": "^4.4.8", "clsx": "^2.1.1", - "date-fns": "^4.1.0", "html5-qrcode": "^2.3.8", "leaflet": "^1.9.4", "lit": "^3.2.1", diff --git a/web/apps/desk/src/components/crm/LogActivityModal.test.ts b/web/apps/desk/src/components/crm/LogActivityModal.test.ts new file mode 100644 index 000000000..968664b23 --- /dev/null +++ b/web/apps/desk/src/components/crm/LogActivityModal.test.ts @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The Log Activity modal's payload leg (PR 156 review P2-4): an activity is + * an event with a time. The form keeps the day's field and adds the app's own + * time entry (hours and minutes, the current time by default, refused in words + * when it is not a time); the wire's activity_date is the picked day at the + * picked time, in the person's own zone. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { GableLogActivityModal } from './LogActivityModal' +import './LogActivityModal' +import { mount, update, q, flush } from '../../test/dom' +import { jsonResponse } from '../../test/dom' + +function serveSpy() { + const calls: Array<{ url: string; body: string }> = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url: String(url), body: String(init?.body ?? '') }) + return jsonResponse({ id: 'a-1' }) + }), + ) + return calls +} + +async function fillAndSubmit(el: GableLogActivityModal, day: string, time: string) { + // Drive the day field as a person does: type the day, leave the box; the + // field commits and fires change, which the modal reads. (Assigning the + // field's value property fires nothing, and the modal would save its + // default day instead.) + const field = q(el, 'gable-date-field') + const box = q(field, 'input[type="text"]') + box.value = day + box.dispatchEvent(new Event('input', { bubbles: true })) + box.dispatchEvent(new FocusEvent('blur')) + await update(el, {}) + const timeBox = q(el, 'input[aria-label="Activity time"]') + timeBox.value = time + timeBox.dispatchEvent(new Event('input', { bubbles: true })) + const desc = q(el, 'textarea') + desc.value = 'Called about the order' + desc.dispatchEvent(new Event('input', { bubbles: true })) + await update(el, {}) + q(el, 'button[type="submit"]').click() + await flush() +} + +beforeEach(() => { + localStorage.clear() +}) +afterEach(() => { + document.body.innerHTML = '' + vi.unstubAllGlobals() +}) + +describe('Log Activity keeps a time', () => { + it('posts the picked day at the picked time', async () => { + // Under a western zone, so the local day and the UTC day differ and the + // wire's zone handling is what is proven (CI runs in UTC). + const tz = process.env.TZ + process.env.TZ = 'America/Los_Angeles' + try { + const calls = serveSpy() + const el = await mount('gable-log-activity-modal', { customerId: 'c-1' }) + await fillAndSubmit(el, '2026-10-14', '09:30') + const post = calls.find((c) => c.url.includes('/activities')) + expect(post).toBeTruthy() + const body = JSON.parse(post!.body) + expect(body.activity_date).toBe('2026-10-14T16:30:00.000Z') + expect(body.activity_date).not.toContain('T12:00') + } finally { + if (tz === undefined) delete process.env.TZ; else process.env.TZ = tz + } + }) + + it('defaults the time to now and refuses a non time in words', async () => { + const el = await mount('gable-log-activity-modal', { customerId: 'c-1' }) + const timeBox = q(el, 'input[aria-label="Activity time"]') + expect(timeBox.value).toMatch(/^\d{2}:\d{2}$/) + const calls = serveSpy() + await fillAndSubmit(el, '2026-10-14', 'whenever') + expect(calls.find((c) => c.url.includes('/activities'))).toBeUndefined() + }) +}) diff --git a/web/apps/desk/src/components/crm/LogActivityModal.ts b/web/apps/desk/src/components/crm/LogActivityModal.ts index a6ac18a71..81fa8ece0 100644 --- a/web/apps/desk/src/components/crm/LogActivityModal.ts +++ b/web/apps/desk/src/components/crm/LogActivityModal.ts @@ -6,6 +6,25 @@ 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'; + +// The current hour and minute, as the time entry's default. +function nowTime(): string { + const n = new Date(); + return `${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}`; +} + +// The app's own time entry takes hours and minutes (09:30) and nothing else. +function parseTime(text: string): string | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; +} +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 +38,28 @@ 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 _activityTime = nowTime(); @state() private _contactId = ''; private async _handleSubmit(e: Event) { e.preventDefault(); try { + if (!this._activityDay) { + ToastService.show('Choose a date', 'error'); + return; + } + const time = parseTime(this._activityTime); + if (!time) { + ToastService.show('Enter the time as hours and minutes, for example 09:30', 'error'); + return; + } this._loading = true; const payload: CreateActivityRequest = { activity_type: this._activityType, description: this._description, - activity_date: new Date(this._activityDate).toISOString(), + // The day and time the person entered, in their own zone. + activity_date: new Date(`${this._activityDay}T${time}:00`).toISOString(), contact_id: this._contactId === '' ? undefined : this._contactId, }; await crmApi.createActivity(this.customerId, payload); @@ -76,15 +106,29 @@ export class GableLogActivityModal extends LitElement {
- - this._activityDate = (e.target as HTMLInputElement).value} - class="bg-zinc-800 text-white w-full px-3 py-2 border border-white/20 rounded-md focus:ring-blue-500 focus:border-blue-500" - /> +
+
+ + this._activityDay = (e.target as GableDateField).value} + > +
+
+ + this._activityTime = (e.target as HTMLInputElement).value} + class="bg-zinc-800 text-white w-full px-3 py-2 border border-white/20 rounded-md focus:ring-2 focus:ring-blueprint-blue/50 focus:border-blueprint-blue" + /> +
+
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 56fef2b15..0c9a9bdfb 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-deep-space 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 5adb00eb7..95f90734a 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 633696aeb..809867ba5 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..497815087 --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-date-field.test.ts @@ -0,0 +1,348 @@ +// 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' +import { placePopover } from './gable-date-field' + +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('keeps focus on the day it lands on when the month turns', async () => { + // Jan 31, ArrowRight into February: the old cell's node goes away; the + // focus must land on the Feb 1 button, not the body. + const el = await mount(TAG, { value: '2026-01-31' }) + await openCalendar(el) + const start = gridDays(el).find((b) => b.tabIndex === 0) + if (!start) throw new Error('no focused cell') + start.focus() + expect(document.activeElement).toBe(start) + await key(el, 'ArrowRight') + const landed = gridDays(el).find((b) => b.tabIndex === 0) + expect(el.textContent).toContain('February 2026') + expect(landed?.textContent).toBe('1') + expect(document.activeElement).toBe(landed) + // From there the keys still reach the grid: Escape closes. + await key(el, 'Escape') + expect(el.querySelector('[role="dialog"]')).toBeNull() + }) + + it('the focusout Chromium sends on removal does not lose the focus', async () => { + // Chromium fires blur and focusout (relatedTarget null) on the focused + // cell when the render removes it; jsdom fires nothing. The sequence is + // replayed by hand: the key lands, the focusout follows before the + // render settles, and the Feb 1 button must still end up focused. + const el = await mount(TAG, { value: '2026-01-31' }) + await openCalendar(el) + const start = gridDays(el).find((b) => b.tabIndex === 0) + if (!start) throw new Error('no focused cell') + start.focus() + q(el, '[role="grid"]').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ) + start.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: null })) + await update(el, {}) + const landed = gridDays(el).find((b) => b.tabIndex === 0) + expect(el.textContent).toContain('February 2026') + expect(landed?.textContent).toBe('1') + expect(document.activeElement).toBe(landed) + }) + + 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 and the keys clamp to them', 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) + // The keys never land on a disabled day: two weeks up from the 14th + // clamps to the 10th, and Enter picks it. + await key(el, 'ArrowUp') + await key(el, 'ArrowUp') + const focused = gridDays(el).find((b) => b.tabIndex === 0) + expect(focused?.textContent).toBe('10') + await key(el, 'Enter') + expect(el.value).toBe('2026-10-10') + }) +}) + +describe('the popover stays inside the screen', () => { + it('placePopover clamps sideways and flips upward when the space below is short', () => { + // Room below: under the field, at the field's left. + expect(placePopover({ left: 100, bottom: 200, top: 176 } as DOMRect, 1024, 768)) + .toEqual({ top: 204, left: 100 }) + // At the right edge: clamped to the viewport. + expect(placePopover({ left: 1000, bottom: 200, top: 176 } as DOMRect, 1024, 768).left) + .toBe(1024 - 264) + // At the left edge: clamped to the margin. + expect(placePopover({ left: -30, bottom: 200, top: 176 } as DOMRect, 1024, 768).left).toBe(8) + // Near the bottom with more room above: flipped over the field. + expect(placePopover({ left: 100, bottom: 740, top: 716 } as DOMRect, 1024, 768)) + .toEqual({ top: 716 - 320 - 4, left: 100 }) + // A viewport too short for either side: flipped, clamped to the top margin. + const p = placePopover({ left: 100, bottom: 204, top: 180 } as DOMRect, 1024, 340) + expect(p.top).toBe(8) + }) + + it('the open popover is fixed at the field, not clipped by a modal', async () => { + const el = await mount(TAG, { value: '2026-10-14' }) + await openCalendar(el) + const pop = q(el, '[role="dialog"]') + expect(pop.style.position).toBe('fixed') + expect(pop.style.top).toBe('4px') + expect(pop.style.left).toBe('8px') + }) +}) + +describe('the field follows its host surface', () => { + it('token classes, so the theme follows the host', async () => { + const el = await mount(TAG) + expect(input(el).className).toContain('text-white') + }) +}) + +describe('the calendar opens in the browser top layer', () => { + it('inside a transform and overflow hidden ancestor, nothing can offset or clip it', async () => { + // jsdom has no popover API; stub it to watch the calls a real engine gets. + const proto = HTMLElement.prototype as unknown as { + showPopover?: (this: HTMLElement) => void + hidePopover?: (this: HTMLElement) => void + } + const shown: HTMLElement[] = [] + proto.showPopover = function (this: HTMLElement) { shown.push(this) } + proto.hidePopover = function (this: HTMLElement) { /* no-op */ } + try { + const host = document.createElement('div') + host.style.transform = 'translateX(40px)' + host.style.overflow = 'hidden' + document.body.appendChild(host) + const el = document.createElement('gable-date-field') as GableDateField + el.value = '2026-10-14' + host.appendChild(el) + await update(el, {}) + await openCalendar(el) + const pop = q(el, '[role="dialog"]') + expect(pop.getAttribute('popover')).toBe('manual') + expect(shown).toContain(pop) + // Coordinates still come from the field's own box on the screen. + expect(pop.style.position).toBe('fixed') + expect(pop.style.top).toBe('4px') + await key(el, 'Escape') + expect(el.querySelector('[role="dialog"]')).toBeNull() + host.remove() + } finally { + delete proto.showPopover + delete proto.hidePopover + } + }) +}) + +describe('the popover defaults are cleared', () => { + it('inset and margin are cleared before top and left', async () => { + // The browser gives every popover inset: 0 and margin: auto, which would + // centre the calendar in the space below and right of its field. jsdom has + // no popover stylesheet, so the leg reads the inline styles: every edge is + // written (the unused two auto), the margin cleared, ahead of nothing. + const el = await mount(TAG, { value: '2026-10-14' }) + await openCalendar(el) + const pop = q(el, '[role="dialog"]') + expect(pop.style.margin).toBe('0px') + expect(pop.style.right).toBe('auto') + expect(pop.style.bottom).toBe('auto') + expect(pop.style.top).toBe('4px') + expect(pop.style.left).toBe('8px') + }) +}) 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..ec746b77f --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-date-field.ts @@ -0,0 +1,438 @@ +// 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; + +// The popover's box: w-64 is 256px; the grid with its header is about 320px. +const POPOVER_W = 256; +const POPOVER_H = 320; +const POPOVER_GAP = 4; +const VIEWPORT_MARGIN = 8; + +/** + * Where the calendar pops, from the field's box and the viewport: under the + * field, clamped sideways into the screen, and flipped above the field when + * the space below is short and the space above is longer. Fixed positioning, + * so a modal's scroll or hidden overflow never clips it. + */ +export function placePopover( + rect: { left: number; bottom: number; top: number }, + viewportW: number, + viewportH: number, +): { top: number; left: number } { + const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left, viewportW - POPOVER_W - VIEWPORT_MARGIN)); + const below = viewportH - rect.bottom; + if (below < POPOVER_H + POPOVER_GAP && rect.top > below) { + return { top: Math.max(VIEWPORT_MARGIN, rect.top - POPOVER_H - POPOVER_GAP), left }; + } + const top = rect.bottom + POPOVER_GAP; + return { top: Math.min(top, viewportH - POPOVER_H - VIEWPORT_MARGIN), left }; +} + +@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 = ''; + @state() private _popTop = 0; + @state() private _popLeft = 0; + + private _id = `gable-date-field-${++nextId}`; + // True while the grid holds the keyboard focus, so a re-render that + // replaces the focused cell's node (the month turned) knows to give the + // focus to the day the move landed on. + private _gridFocus = false; + // Set by the grid's own key handler and the month buttons: a key or a + // click there proves the calendar had the interaction, independent of + // what focus events the engine fires while the old cell's node goes away + // (Chromium sends a focusout on removal, jsdom none). updated() consumes + // it to land the focus on the moved-to day. + private _refocus = false; + private _outside = (e: MouseEvent) => { + if (this._open && !this.contains(e.target as Node)) this._close(); + }; + // The calendar is placed from the field's box on the screen; a scroll or + // resize moves that box, so an open calendar closes rather than floats + // free of its field. + private _repositioning = () => this._close(); + + connectedCallback() { + super.connectedCallback(); + document.addEventListener('mousedown', this._outside); + } + + disconnectedCallback() { + document.removeEventListener('mousedown', this._outside); + this._unwatchViewport(); + super.disconnectedCallback(); + } + + private _watchViewport() { + window.addEventListener('scroll', this._repositioning, true); + window.addEventListener('resize', this._repositioning); + } + + private _unwatchViewport() { + window.removeEventListener('scroll', this._repositioning, true); + window.removeEventListener('resize', this._repositioning); + } + + 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; + const rect = this.querySelector('input')?.getBoundingClientRect(); + if (rect) { + const at = placePopover(rect, window.innerWidth, window.innerHeight); + this._popTop = at.top; + this._popLeft = at.left; + } + this._open = true; + this._watchViewport(); + } + + private _close() { + if (this._open) this._unwatchViewport(); + 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); + } + let next = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + // The focus never sits on a disabled day: clamp to the bounds. + if (this.min && next < this.min) next = this.min; + if (this.max && next > this.max) next = this.max; + this._focusDay = next; + this._viewYear = Number(next.slice(0, 4)); + this._viewMonth = Number(next.slice(5, 7)); + } + + 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._refocus = true; this._move(day, -1, 0); break; + case 'ArrowRight': e.preventDefault(); this._refocus = true; this._move(day, 1, 0); break; + case 'ArrowUp': e.preventDefault(); this._refocus = true; this._move(day, -7, 0); break; + case 'ArrowDown': e.preventDefault(); this._refocus = true; this._move(day, 7, 0); break; + case 'PageUp': e.preventDefault(); this._refocus = true; this._move(day, 0, e.shiftKey ? -12 : -1); break; + case 'PageDown': e.preventDefault(); this._refocus = true; this._move(day, 0, e.shiftKey ? 12 : 1); break; + case 'Home': e.preventDefault(); this._refocus = true; this._move(day, -weekday, 0); break; + case 'End': e.preventDefault(); this._refocus = true; 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) { + this._refocus = true; + 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(); + let next = `${this._viewYear}-${String(this._viewMonth).padStart(2, '0')}-${String(Math.min(day, last)).padStart(2, '0')}`; + if (this.min && next < this.min) next = this.min; + if (this.max && next > this.max) next = this.max; + this._focusDay = next; + this._viewYear = Number(next.slice(0, 4)); + this._viewMonth = Number(next.slice(5, 7)); + } + + /** 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) { + // The dialog carries popover="manual": on a real engine this puts + // it in the top layer, which no ancestor's transform, filter or + // overflow can offset or clip. Manual keeps the dismissal ours + // (the outside click and the Escape handlers). A popover not yet + // shown is display:none, so show it on every render while open. + const pop = this.querySelector('[popover]') as (HTMLElement & { showPopover?: () => void }) | null; + if (pop && typeof pop.showPopover === 'function') { + try { + pop.showPopover(); + } catch { + // already showing + } + } + const btn = this.querySelector(`#${this._id}-grid button[tabindex="0"]`) as HTMLElement | null; + // Pull focus to the landed day when a grid key or month button + // moved (the engine's focus events during removal are beside the + // point), when the grid had the focus before the render, or when + // the calendar just opened from the field. + if (btn && document.activeElement !== btn && (this._refocus || this._gridFocus || this.contains(document.activeElement))) { + btn.focus(); + } + this._refocus = false; + } else { + this._gridFocus = false; + this._refocus = false; + } + } +} + +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.guard.test.ts b/web/apps/desk/src/lib/dates.guard.test.ts new file mode 100644 index 000000000..b3e6fbeb3 --- /dev/null +++ b/web/apps/desk/src/lib/dates.guard.test.ts @@ -0,0 +1,102 @@ +// 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), + * quoted, unquoted or bound, + * - 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, + * - a `toLocaleString(` on a date (built from a Date, or called with date + * options), a `toDateString(` or `toUTCString(`, or a `date-fns` import, + * the other places a date hides in the browser's own format. + * + * 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: /(?:^|\s|\.)type\s*=\s*(?:\$\{\s*)?['"]?(?:date|datetime-local|month|time)['"]?(?=[\s}>/]|$)/, + }, + { kind: 'toLocaleDateString(', pattern: /\.toLocaleDateString\s*\(/ }, + { kind: 'toLocaleTimeString(', pattern: /\.toLocaleTimeString\s*\(/ }, + { kind: 'Intl.DateTimeFormat(', pattern: /Intl\.DateTimeFormat\s*\(/ }, + // toLocaleString on a date: built from a Date, or called with date options + // (numbers never take those). A plain toLocaleString() on a number stays. + { kind: 'toLocaleString( on a date', pattern: /new Date\([^)]*\)\.toLocaleString\s*\(/ }, + { + kind: 'toLocaleString( with date options', + pattern: /\.toLocaleString\s*\([^)]*\b(?:dateStyle|timeStyle|month|weekday|day|year|hour)\b/, + }, + { kind: 'toDateString(', pattern: /\.toDateString\s*\(/ }, + { kind: 'toUTCString(', pattern: /\.toUTCString\s*\(/ }, + { kind: 'date-fns import', pattern: /from\s+['"]date-fns['"]/ }, +] + +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([]) + }) + + // The native input rule against the forms this codebase writes: the line's + // own end after the value (the usual multi-line form), inline, unquoted and + // bound. Planted, so a rule that misses one fails here and not in a page. + it('catches a native date input at a line\'s end, inline, unquoted and bound', () => { + const rule = RULES.find((r) => r.kind === 'native date/time input') + expect(rule).toBeTruthy() + const hits = [ + 'type="date"', + '', + 'type="datetime-local"', + 'type="month"', + 'type="time" />', + 'type=date>', + ".type=${'date'}", + ] + for (const h of hits) expect(rule!.pattern.test(h), h).toBe(true) + for (const miss of ['type="text"', 'type="number"', 'type="daterange"', 'prototype="date"']) { + expect(rule!.pattern.test(miss), miss).toBe(false) + } + }) +}) 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..00f615d8b --- /dev/null +++ b/web/apps/desk/src/lib/dates.test.ts @@ -0,0 +1,207 @@ +// 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 { + if (tz === undefined) delete process.env.TZ; else 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') + }) +}) + +describe('formatDay on a timestamp', () => { + it('a business day sent as a midnight UTC timestamp is its day part', () => { + expect(formatDay('2026-10-14T00:00:00Z')).toBe('Oct 14, 2026') + expect(formatDay('2026-10-14T00:00:00.000Z')).toBe('Oct 14, 2026') + }) + it('the day part never shifts west of UTC', () => { + const tz = process.env.TZ + process.env.TZ = 'America/Los_Angeles' + try { + // Midnight UTC is still the afternoon before out west; the day the + // server means is the day part. + expect(new Date('2026-10-14T00:00:00Z').getDate()).toBe(13) + expect(formatDay('2026-10-14T00:00:00Z')).toBe('Oct 14, 2026') + } finally { + if (tz === undefined) delete process.env.TZ; else process.env.TZ = tz + } + }) + it('never shows raw text for a value that parses', () => { + // A real moment (a journal entry's timestamp) is formatted as a moment's + // day in the person's own zone, not passed through as machine text. + expect(formatDay('2026-10-14T15:04:00')).toBe('Oct 14, 2026') + expect(formatDay('October 14, 2026')).toBe('Oct 14, 2026') + }) +}) + +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..3e66f0de4 --- /dev/null +++ b/web/apps/desk/src/lib/dates.ts @@ -0,0 +1,244 @@ +// 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 rendered in the dealer's locale. A plain day (YYYY-MM-DD, +// no time zone) is built from its parts so a west-of-UTC machine never shows +// the day before; a business day the server sends as a midnight UTC timestamp +// (a DATE column in a Go time.Time) is formatted from its day part; any other +// value that parses is a moment, formatted as that moment's day in the +// person's own time zone, so machine text never reaches the screen; anything +// that does not parse at all is returned as it came. +const PLAIN_DAY = /^(\d{4})-(\d{2})-(\d{2})$/; +const MIDNIGHT_UTC = /^(\d{4})-(\d{2})-(\d{2})T00:00:00(\.\d+)?Z$/; + +export function formatDay(day: string | null | undefined): string { + if (!day) return '—'; + const m = PLAIN_DAY.exec(day) || MIDNIGHT_UTC.exec(day); + if (m) { + return dayFormatter().format(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + } + const d = new Date(day); + if (isNaN(d.getTime())) return day; + return dayFormatter().format(d); +} + +// 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 aa6f25cd6..1e390c2ea 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'; import { initTheme } from '@gable/design-system'; import { authCustody } from '@gable/auth'; @@ -18,11 +19,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/invoices/InvoiceDetail.ts b/web/apps/desk/src/pages/invoices/InvoiceDetail.ts index 3de3e5745..1ec65a70b 100644 --- a/web/apps/desk/src/pages/invoices/InvoiceDetail.ts +++ b/web/apps/desk/src/pages/invoices/InvoiceDetail.ts @@ -23,6 +23,7 @@ import type { PaymentApplication, CreatePaymentRequest } from '../../types/payme import { Download, CreditCard, Mail, RotateCcw, Ban } from 'lucide'; import { formatCents, formatDay } from '../../lib/utils.ts'; import { creditTextClass } from '../../lib/credit-display.ts'; +import { formatDate, formatDateTime } from '../../lib/dates.ts'; import { chipClass, overdueBadge } from '../../components/invoices/chips.ts'; import { renderSalesLines } from '../../components/invoices/lines-table.ts'; @@ -235,7 +236,7 @@ export class GableInvoiceDetail extends LitElement {
VOID
- This invoice was voided${inv.voided_at ? html` on ${new Date(inv.voided_at).toLocaleString()}` : nothing}${inv.voided_by ? html` by ${inv.voided_by}` : nothing}. + This invoice was voided${inv.voided_at ? html` on ${formatDateTime(inv.voided_at)}` : nothing}${inv.voided_by ? html` by ${inv.voided_by}` : nothing}. ${inv.void_reason ? html`Reason: ${inv.void_reason}` : nothing}
@@ -313,9 +314,9 @@ export class GableInvoiceDetail extends LitElement { ${this.applications.map(a => html` - ${new Date(a.created_at).toLocaleString()} + ${formatDateTime(a.created_at)} ${a.kind} - ${a.reversed_at ? `reversed ${new Date(a.reversed_at).toLocaleDateString()}` : 'live'} + ${a.reversed_at ? `reversed ${formatDate(a.reversed_at)}` : 'live'} ${formatCents(a.amount_cents)} `)} @@ -355,7 +356,7 @@ export class GableInvoiceDetail extends LitElement {
Origin
${inv.origin === 'pos' ? 'Counter' : 'Order'}
Delivery
${inv.delivery_type === 'pickup' ? 'Pickup' : 'Delivery'}
${inv.picked_up_by ? html`
Picked up by
${inv.picked_up_by}
` : nothing} - ${inv.paid_at ? html`
Paid
${new Date(inv.paid_at).toLocaleDateString()}
` : nothing} + ${inv.paid_at ? html`
Paid
${formatDate(inv.paid_at)}
` : nothing}
diff --git a/web/apps/desk/src/pages/logistics/FleetManagement.ts b/web/apps/desk/src/pages/logistics/FleetManagement.ts index 72975d23a..cc41324e5 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 9747cde80..8198f6893 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 adc39c9ed..5a007b76d 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'; import { cssVarColor } from '../../lib/theme-colors.ts'; @@ -146,7 +147,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 31957fbd6..15a6b75a5 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'; import { cssVarColor } from '../../lib/theme-colors.ts'; @@ -21,7 +22,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 } => { @@ -160,8 +161,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)}` + : html`${formatDate(del.created_at)}` }
@@ -239,7 +240,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 5e7025f44..151d3f4a0 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'; import { cssVarColor } from '../../lib/theme-colors.ts'; @@ -62,10 +63,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)}` : '', '', `Subtotal: ${formatCurrency(invoice.subtotal)}`, `Tax: ${formatCurrency(invoice.tax_amount)}`, @@ -138,7 +139,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)}` : ''}
diff --git a/web/apps/desk/src/pages/portal/PortalOrders.ts b/web/apps/desk/src/pages/portal/PortalOrders.ts index a808acf7e..402591012 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'; @@ -132,7 +133,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 aeb91b09c..51cdde966 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 cbb392a04..9e8c1e575 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 0c475ffa7..ac830730b 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 6df82c3f1..d67a3f7cd 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 bb33d75d3..6003f1fa8 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 e34aeca18..83228429b 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 edf2ab973..ac7679161 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-gable-green 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-gable-green outline-none" - /> + @change=${(e: Event) => this.endDate = (e.target as GableDateField).value} + >