From bc774a0229c3c90080a70a396a449e63d1e95552 Mon Sep 17 00:00:00 2001 From: EdiWeeks <121399761+EdiWeeks@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:53:36 +0100 Subject: [PATCH 1/3] fix: decode BC OData enum encoding so G/L Account lines show in breakdowns BC's OData serializer URL-encodes named enum values, so a planning line of type "G/L Account" arrives as "G_x002F_L_x0020_Account". Thyme compared the raw value against the plain "G/L Account" string, so G/L Account planning lines never matched and their amounts showed as 0 in the Budget Cost and Billable Price per-type breakdowns on the project details page (confirmed against live BC for PR00100 task 9999). Add decodeBCEnum() to decode the _xHHHH_ escapes generically and apply it to type/lineType once at the fetch boundary in bcClient, so all downstream comparisons use the plain values. Fixes #211 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/bc/bcClient.ts | 18 +++++++++++++++--- src/utils/unitConversion.ts | 19 +++++++++++++++++++ tests/unit/utils/unitConversion.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/services/bc/bcClient.ts b/src/services/bc/bcClient.ts index 8d46201..9b6600a 100644 --- a/src/services/bc/bcClient.ts +++ b/src/services/bc/bcClient.ts @@ -20,7 +20,7 @@ import type { TimesheetDisplayStatus, PaginatedResponse, } from '@/types'; -import { getTimesheetDisplayStatus } from '@/utils'; +import { getTimesheetDisplayStatus, decodeBCEnum } from '@/utils'; const BC_BASE_URL = process.env.NEXT_PUBLIC_BC_BASE_URL || 'https://api.businesscentral.dynamics.com/v2.0'; @@ -466,6 +466,18 @@ class BusinessCentralClient { return this.fetch(`/jobTasks(${jobTaskId})`); } + // Decode BC's OData enum encoding on planning-line fields so downstream code + // can compare against the plain values. BC serializes named enums like + // `type` "G/L Account" as "G_x002F_L_x0020_Account" and `lineType` + // "Both Budget and Billable" as "Both_x0020_Budget_x0020_and_x0020_Billable". + private normalizePlanningLines(lines: BCJobPlanningLine[]): BCJobPlanningLine[] { + return lines.map((line) => ({ + ...line, + type: decodeBCEnum(line.type) as BCJobPlanningLine['type'], + lineType: decodeBCEnum(line.lineType) as BCJobPlanningLine['lineType'], + })); + } + // Job Planning Lines - requires Thyme BC Extension v1.6.0+ // Provides budget/planned hours data for projects async getJobPlanningLines(jobNumber: string): Promise { @@ -502,7 +514,7 @@ class BusinessCentralClient { } const data = await response.json(); - return data.value || []; + return this.normalizePlanningLines(data.value || []); } catch (error) { console.error('[BC API] Error fetching job planning lines:', error); return []; @@ -592,7 +604,7 @@ class BusinessCentralClient { } const data = await response.json(); - return data.value || []; + return this.normalizePlanningLines(data.value || []); } catch (error) { console.error('[BC API] Error fetching job planning lines for week:', error); return []; diff --git a/src/utils/unitConversion.ts b/src/utils/unitConversion.ts index 8de58e7..4182972 100644 --- a/src/utils/unitConversion.ts +++ b/src/utils/unitConversion.ts @@ -124,6 +124,25 @@ export function formatHours(hours: number): string { return parseFloat(hours.toFixed(2)).toString(); } +/** + * Decode BC's OData enum-value encoding. + * + * BC's OData JSON serializer URL-encodes any character that isn't valid in an + * identifier as `_xHHHH_`, where HHHH is the UTF-16 code unit in hex. So the + * planning-line type "G/L Account" arrives as "G_x002F_L_x0020_Account" + * (`/` → `_x002F_`, space → `_x0020_`), and the lineType "Both Budget and + * Billable" arrives as "Both_x0020_Budget_x0020_and_x0020_Billable". + * + * Decoding once at the data boundary lets all downstream comparisons use the + * plain, human-readable values (e.g. `type === 'G/L Account'`). + */ +export function decodeBCEnum(value: string | undefined): string { + if (!value) return ''; + return value.replace(/_x([0-9A-Fa-f]{4})_/g, (_match, hex) => + String.fromCharCode(parseInt(hex, 16)) + ); +} + /** * Whether a planning line's `lineType` counts as a Budget line. * diff --git a/tests/unit/utils/unitConversion.test.ts b/tests/unit/utils/unitConversion.test.ts index d14bf59..f4d3c24 100644 --- a/tests/unit/utils/unitConversion.test.ts +++ b/tests/unit/utils/unitConversion.test.ts @@ -7,6 +7,7 @@ import { isResourceDayBased, isBudgetPlanningLine, sumPlannedHours, + decodeBCEnum, } from '@/utils/unitConversion'; import type { BCJobPlanningLine, BCResourceUnitOfMeasure } from '@/types'; @@ -201,6 +202,30 @@ describe('unitConversion', () => { }); }); + describe('decodeBCEnum', () => { + it('decodes the "G/L Account" type encoded by BC as "G_x002F_L_x0020_Account"', () => { + // Confirmed against the live /jobPlanningLines response for PR00100 task 9999. + expect(decodeBCEnum('G_x002F_L_x0020_Account')).toBe('G/L Account'); + }); + + it('decodes the "Both Budget and Billable" lineType', () => { + expect(decodeBCEnum('Both_x0020_Budget_x0020_and_x0020_Billable')).toBe( + 'Both Budget and Billable' + ); + }); + + it('leaves already-plain values unchanged', () => { + expect(decodeBCEnum('Resource')).toBe('Resource'); + expect(decodeBCEnum('Budget')).toBe('Budget'); + expect(decodeBCEnum('G/L Account')).toBe('G/L Account'); + }); + + it('returns an empty string for undefined or empty input', () => { + expect(decodeBCEnum(undefined)).toBe(''); + expect(decodeBCEnum('')).toBe(''); + }); + }); + describe('sumPlannedHours', () => { it('sums Resource Budget lines, converting DAY quantities to hours', () => { const map = buildUOMConversionMap([makeUOM('R001', 'HOUR', 7.5)]); From 7a7acbaa94af0be2a4c87acd4dbd92e903aff45d Mon Sep 17 00:00:00 2001 From: EdiWeeks <121399761+EdiWeeks@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:11:34 +0100 Subject: [PATCH 2/3] refactor: address Copilot review on enum decoding - decodeBCEnum: scan left-to-right with a sticky regex so a literal underscore escape (_x005F_) is consumed as one unit and a following escape sequence is left intact, rather than re-read. Add tests for the _x005F_ and adjacent-escape cases. - normalizePlanningLines: narrow decoded type/lineType against the known unions and fall back to the original value when unrecognized, instead of a blind cast that could mask unexpected BC enums. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/bc/bcClient.ts | 20 +++++++++++++++++-- src/utils/unitConversion.ts | 26 ++++++++++++++++++++++--- tests/unit/utils/unitConversion.test.ts | 11 +++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/services/bc/bcClient.ts b/src/services/bc/bcClient.ts index 9b6600a..03efa84 100644 --- a/src/services/bc/bcClient.ts +++ b/src/services/bc/bcClient.ts @@ -41,6 +41,22 @@ const THYME_API_VERSION = 'v1.0'; // Valid TimeSheetStatus values for whitelist validation const VALID_TIMESHEET_STATUSES = ['Open', 'Submitted', 'Rejected', 'Approved', 'Posted'] as const; +// Known planning-line enum values, used to narrow decoded BC strings back to +// the typed unions. An unrecognized decode falls back to the original value +// rather than being blindly cast, so unexpected BC enums aren't masked. +const PLANNING_LINE_TYPES = ['Resource', 'Item', 'G/L Account'] as const; +const PLANNING_LINE_LINE_TYPES = [ + 'Budget', + 'Billable', + 'Both Budget and Billable', +] as const; + +// Narrow a decoded enum string to one of the allowed values, falling back to +// the original (still-typed) value when the decode isn't a recognized member. +function narrowEnum(decoded: string, allowed: readonly T[], fallback: T): T { + return (allowed as readonly string[]).includes(decoded) ? (decoded as T) : fallback; +} + // Available environments to query const BC_ENVIRONMENTS: BCEnvironmentType[] = ['sandbox', 'production']; @@ -473,8 +489,8 @@ class BusinessCentralClient { private normalizePlanningLines(lines: BCJobPlanningLine[]): BCJobPlanningLine[] { return lines.map((line) => ({ ...line, - type: decodeBCEnum(line.type) as BCJobPlanningLine['type'], - lineType: decodeBCEnum(line.lineType) as BCJobPlanningLine['lineType'], + type: narrowEnum(decodeBCEnum(line.type), PLANNING_LINE_TYPES, line.type), + lineType: narrowEnum(decodeBCEnum(line.lineType), PLANNING_LINE_LINE_TYPES, line.lineType), })); } diff --git a/src/utils/unitConversion.ts b/src/utils/unitConversion.ts index 4182972..7baaa17 100644 --- a/src/utils/unitConversion.ts +++ b/src/utils/unitConversion.ts @@ -133,14 +133,34 @@ export function formatHours(hours: number): string { * (`/` → `_x002F_`, space → `_x0020_`), and the lineType "Both Budget and * Billable" arrives as "Both_x0020_Budget_x0020_and_x0020_Billable". * + * A literal underscore that would otherwise start an escape is itself encoded + * as `_x005F_`, so a source value that genuinely contains `_x0020_` arrives as + * `_x005F_x0020_`. We scan left-to-right and consume each `_xHHHH_` as a single + * unit, so the `_x005F_` decodes to a literal `_` and the following `x0020_` is + * left untouched (rather than being re-read as a space). + * * Decoding once at the data boundary lets all downstream comparisons use the * plain, human-readable values (e.g. `type === 'G/L Account'`). */ export function decodeBCEnum(value: string | undefined): string { if (!value) return ''; - return value.replace(/_x([0-9A-Fa-f]{4})_/g, (_match, hex) => - String.fromCharCode(parseInt(hex, 16)) - ); + // Sticky regex anchors each match at the current scan position, so we never + // re-scan already-decoded output or share a delimiter `_` between escapes. + const escape = /_x([0-9A-Fa-f]{4})_/y; + let result = ''; + let i = 0; + while (i < value.length) { + escape.lastIndex = i; + const match = escape.exec(value); + if (match) { + result += String.fromCharCode(parseInt(match[1], 16)); + i += match[0].length; + } else { + result += value[i]; + i += 1; + } + } + return result; } /** diff --git a/tests/unit/utils/unitConversion.test.ts b/tests/unit/utils/unitConversion.test.ts index f4d3c24..4cde3fe 100644 --- a/tests/unit/utils/unitConversion.test.ts +++ b/tests/unit/utils/unitConversion.test.ts @@ -220,6 +220,17 @@ describe('unitConversion', () => { expect(decodeBCEnum('G/L Account')).toBe('G/L Account'); }); + it('treats _x005F_ as a literal underscore and leaves the following escape intact', () => { + // A source value that genuinely contains "_x0020_" is encoded as + // "_x005F_x0020_"; it must decode back to the literal "_x0020_", not " ". + expect(decodeBCEnum('_x005F_x0020_')).toBe('_x0020_'); + }); + + it('decodes adjacent escapes independently', () => { + // "//" → each "/" encoded separately as "_x002F_". + expect(decodeBCEnum('_x002F__x002F_')).toBe('//'); + }); + it('returns an empty string for undefined or empty input', () => { expect(decodeBCEnum(undefined)).toBe(''); expect(decodeBCEnum('')).toBe(''); From ba88f906776abb66f13a1e913b4996ca3a184e83 Mon Sep 17 00:00:00 2001 From: EdiWeeks <121399761+EdiWeeks@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:25:54 +0100 Subject: [PATCH 3/3] style: apply prettier to bcClient Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/bc/bcClient.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/services/bc/bcClient.ts b/src/services/bc/bcClient.ts index 03efa84..fa46223 100644 --- a/src/services/bc/bcClient.ts +++ b/src/services/bc/bcClient.ts @@ -45,11 +45,7 @@ const VALID_TIMESHEET_STATUSES = ['Open', 'Submitted', 'Rejected', 'Approved', ' // the typed unions. An unrecognized decode falls back to the original value // rather than being blindly cast, so unexpected BC enums aren't masked. const PLANNING_LINE_TYPES = ['Resource', 'Item', 'G/L Account'] as const; -const PLANNING_LINE_LINE_TYPES = [ - 'Budget', - 'Billable', - 'Both Budget and Billable', -] as const; +const PLANNING_LINE_LINE_TYPES = ['Budget', 'Billable', 'Both Budget and Billable'] as const; // Narrow a decoded enum string to one of the allowed values, falling back to // the original (still-typed) value when the decode isn't a recognized member.