calculation-breakdown 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown-builder.js +242 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown-human-string.js +86 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown.error.js +16 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown.interface.js +2 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown.type.js +2 -0
- package/docs/changelog.md +6 -0
- package/docs/index.md +1 -1
- package/docs/playground/index.html +4 -0
- package/docs/playground/playground.css +35 -2
- package/docs/playground/playground.js +56 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes, starting with 0.1.0. The latest five releases are detailed here; older releases are archived.
|
|
4
4
|
|
|
5
|
+
## 0.1.2 — 2026-10-26
|
|
6
|
+
|
|
7
|
+
- Added an opt-in locale mode to the playground that preserves calculation edits, keeps the default example limited to the calculation, and falls back to semantic keys without generated dictionary placeholders.
|
|
8
|
+
- Made the playground vertically scrollable and its three panels usable on mobile screens.
|
|
9
|
+
- Fixed the deployed playground by including every browser ESM module, and kept release artifact inspection JSON-safe by suppressing duplicate package lifecycle scripts.
|
|
10
|
+
|
|
5
11
|
## 0.1.1 — 2026-10-26
|
|
6
12
|
|
|
7
13
|
- Added the guarded npm, Git tag, release-PR, and GitHub Pages deployment workflow used by `payload-vars`.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { CalculationBreakdownError } from './calculation-breakdown.error.js';
|
|
2
|
+
import { toHumanString, } from './calculation-breakdown-human-string.js';
|
|
3
|
+
const DECIMAL_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
4
|
+
const CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
|
5
|
+
const DURATION_UNITS = new Set([
|
|
6
|
+
'milliseconds',
|
|
7
|
+
'seconds',
|
|
8
|
+
'minutes',
|
|
9
|
+
'hours',
|
|
10
|
+
'days',
|
|
11
|
+
]);
|
|
12
|
+
const ROUNDING_MODES = new Set([
|
|
13
|
+
'up',
|
|
14
|
+
'down',
|
|
15
|
+
'nearest',
|
|
16
|
+
]);
|
|
17
|
+
const GROUP_ROLES = new Set([
|
|
18
|
+
'detail',
|
|
19
|
+
'summary',
|
|
20
|
+
]);
|
|
21
|
+
const GROUP_ORIENTATIONS = new Set([
|
|
22
|
+
'horizontal',
|
|
23
|
+
'vertical',
|
|
24
|
+
]);
|
|
25
|
+
const OPERATORS = new Set([
|
|
26
|
+
'add',
|
|
27
|
+
'subtract',
|
|
28
|
+
'multiply',
|
|
29
|
+
'divide',
|
|
30
|
+
'minimum',
|
|
31
|
+
'maximum',
|
|
32
|
+
]);
|
|
33
|
+
const OPERATOR_ALIASES = {
|
|
34
|
+
sub: 'subtract',
|
|
35
|
+
mul: 'multiply',
|
|
36
|
+
div: 'divide',
|
|
37
|
+
min: 'minimum',
|
|
38
|
+
max: 'maximum',
|
|
39
|
+
};
|
|
40
|
+
function assertSemantic(semantic) {
|
|
41
|
+
if (semantic.trim().length === 0) {
|
|
42
|
+
throw new CalculationBreakdownError('INVALID_SEMANTIC', 'Semantic must be a non-empty string.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function assertDecimal(value) {
|
|
46
|
+
if (!DECIMAL_PATTERN.test(value)) {
|
|
47
|
+
throw new CalculationBreakdownError('INVALID_DECIMAL', `Invalid decimal string: ${value}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function assertDurationOptions(options) {
|
|
51
|
+
if (!options)
|
|
52
|
+
return;
|
|
53
|
+
const hasRounding = options.rounding !== undefined;
|
|
54
|
+
const hasStep = options.step !== undefined;
|
|
55
|
+
const step = options.step;
|
|
56
|
+
if (hasRounding &&
|
|
57
|
+
!ROUNDING_MODES.has(options.rounding)) {
|
|
58
|
+
throw new CalculationBreakdownError('INVALID_ROUNDING', `Invalid rounding mode: ${String(options.rounding)}`);
|
|
59
|
+
}
|
|
60
|
+
if (hasRounding !== hasStep) {
|
|
61
|
+
throw new CalculationBreakdownError('INVALID_ROUNDING_STEP', 'Rounding and step must be provided together.');
|
|
62
|
+
}
|
|
63
|
+
if (hasStep &&
|
|
64
|
+
(step === undefined || !Number.isFinite(step) || step <= 0)) {
|
|
65
|
+
throw new CalculationBreakdownError('INVALID_ROUNDING_STEP', 'Rounding step must be a positive finite number.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function deepFreeze(value) {
|
|
69
|
+
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) {
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
Object.freeze(value);
|
|
73
|
+
for (const child of Object.values(value))
|
|
74
|
+
deepFreeze(child);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function cloneJson(value) {
|
|
78
|
+
return JSON.parse(JSON.stringify(value));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Records a typed, serializable breakdown of a calculation performed elsewhere.
|
|
82
|
+
*
|
|
83
|
+
* This builder does not evaluate operations or calculate the result. The calling
|
|
84
|
+
* application supplies every value, including the authoritative result.
|
|
85
|
+
*
|
|
86
|
+
* @typeParam Semantic - String union used to label the domain meaning of values.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* const breakdown = new CalculationBreakdownBuilder<'price' | 'total'>()
|
|
91
|
+
* .breakdown(({ money, constant, multiply }) =>
|
|
92
|
+
* multiply(money('price', '100.00', 'USD'), constant('0.9')),
|
|
93
|
+
* )
|
|
94
|
+
* .result(({ money }) => money('total', '90.00', 'USD'))
|
|
95
|
+
* .build();
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export class CalculationBreakdownBuilder {
|
|
99
|
+
breakdownValue;
|
|
100
|
+
resultValue;
|
|
101
|
+
nodeBuilder = {
|
|
102
|
+
money: (semantic, amount, currency) => this.createMoney(semantic, amount, currency),
|
|
103
|
+
duration: (semantic, amount, unit, options) => this.createDuration(semantic, amount, unit, options),
|
|
104
|
+
number: (semantic, value) => this.createNumber(semantic, value),
|
|
105
|
+
percent: (semantic, value) => this.createPercent(semantic, value),
|
|
106
|
+
constant: (value) => this.createConstant(value),
|
|
107
|
+
group: (expression, options) => this.createGroup(expression, options),
|
|
108
|
+
operator: (left, operator, right) => this.createOperation(left, operator, right),
|
|
109
|
+
add: (left, right) => this.createOperation(left, 'add', right),
|
|
110
|
+
subtract: (left, right) => this.createOperation(left, 'subtract', right),
|
|
111
|
+
sub: (left, right) => this.createOperation(left, 'sub', right),
|
|
112
|
+
multiply: (left, right) => this.createOperation(left, 'multiply', right),
|
|
113
|
+
mul: (left, right) => this.createOperation(left, 'mul', right),
|
|
114
|
+
divide: (left, right) => this.createOperation(left, 'divide', right),
|
|
115
|
+
div: (left, right) => this.createOperation(left, 'div', right),
|
|
116
|
+
minimum: (left, right) => this.createOperation(left, 'minimum', right),
|
|
117
|
+
min: (left, right) => this.createOperation(left, 'min', right),
|
|
118
|
+
maximum: (left, right) => this.createOperation(left, 'maximum', right),
|
|
119
|
+
max: (left, right) => this.createOperation(left, 'max', right),
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Sets the root node that describes the calculation.
|
|
123
|
+
*
|
|
124
|
+
* @param create - Callback that describes the calculation using typed factories.
|
|
125
|
+
* @returns This builder for fluent chaining.
|
|
126
|
+
*/
|
|
127
|
+
breakdown(create) {
|
|
128
|
+
this.breakdownValue = create(this.nodeBuilder);
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Sets the authoritative result calculated by the calling application.
|
|
133
|
+
*
|
|
134
|
+
* @param create - Callback that constructs a money, duration, or number value.
|
|
135
|
+
* @returns This builder for fluent chaining.
|
|
136
|
+
*/
|
|
137
|
+
result(create) {
|
|
138
|
+
this.resultValue = create(this.nodeBuilder);
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Produces a detached, deeply frozen calculation breakdown.
|
|
143
|
+
*
|
|
144
|
+
* @throws {CalculationBreakdownError} If either the breakdown or result is missing.
|
|
145
|
+
*/
|
|
146
|
+
build() {
|
|
147
|
+
if (!this.breakdownValue) {
|
|
148
|
+
throw new CalculationBreakdownError('MISSING_BREAKDOWN', 'A breakdown is required before building.');
|
|
149
|
+
}
|
|
150
|
+
if (!this.resultValue) {
|
|
151
|
+
throw new CalculationBreakdownError('MISSING_RESULT', 'A result is required before building.');
|
|
152
|
+
}
|
|
153
|
+
return deepFreeze(cloneJson({
|
|
154
|
+
breakdown: this.breakdownValue,
|
|
155
|
+
result: this.resultValue,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
/** Builds and renders the calculation using localized semantic labels. */
|
|
159
|
+
toHumanString(options) {
|
|
160
|
+
return toHumanString(this.build(), options);
|
|
161
|
+
}
|
|
162
|
+
createMoney(semantic, amount, currency) {
|
|
163
|
+
assertSemantic(semantic);
|
|
164
|
+
assertDecimal(amount);
|
|
165
|
+
if (!CURRENCY_PATTERN.test(currency)) {
|
|
166
|
+
throw new CalculationBreakdownError('INVALID_CURRENCY', `Currency must be a three-letter uppercase code: ${currency}`);
|
|
167
|
+
}
|
|
168
|
+
return deepFreeze({ type: 'money', semantic, amount, currency });
|
|
169
|
+
}
|
|
170
|
+
createDuration(semantic, amount, unit, options) {
|
|
171
|
+
assertSemantic(semantic);
|
|
172
|
+
assertDecimal(amount);
|
|
173
|
+
if (!DURATION_UNITS.has(unit)) {
|
|
174
|
+
throw new CalculationBreakdownError('INVALID_DURATION_UNIT', `Unsupported duration unit: ${unit}`);
|
|
175
|
+
}
|
|
176
|
+
assertDurationOptions(options);
|
|
177
|
+
return deepFreeze({
|
|
178
|
+
type: 'duration',
|
|
179
|
+
semantic,
|
|
180
|
+
amount,
|
|
181
|
+
unit,
|
|
182
|
+
...(options?.rounding ? { rounding: options.rounding } : {}),
|
|
183
|
+
...(options?.step !== undefined ? { step: options.step } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
createNumber(semantic, value) {
|
|
187
|
+
assertSemantic(semantic);
|
|
188
|
+
assertDecimal(value);
|
|
189
|
+
return deepFreeze({ type: 'number', semantic, value });
|
|
190
|
+
}
|
|
191
|
+
createPercent(semantic, value) {
|
|
192
|
+
assertSemantic(semantic);
|
|
193
|
+
assertDecimal(value);
|
|
194
|
+
return deepFreeze({ type: 'percent', semantic, value });
|
|
195
|
+
}
|
|
196
|
+
createConstant(value) {
|
|
197
|
+
assertDecimal(value);
|
|
198
|
+
return deepFreeze({ type: 'constant', value });
|
|
199
|
+
}
|
|
200
|
+
createGroup(expression, options) {
|
|
201
|
+
if (options?.semantic !== undefined) {
|
|
202
|
+
assertSemantic(options.semantic);
|
|
203
|
+
}
|
|
204
|
+
if (options?.role !== undefined &&
|
|
205
|
+
!GROUP_ROLES.has(options.role)) {
|
|
206
|
+
throw new CalculationBreakdownError('INVALID_GROUP_ROLE', `Unsupported group role: ${String(options.role)}`);
|
|
207
|
+
}
|
|
208
|
+
if (options?.orientation !== undefined &&
|
|
209
|
+
!GROUP_ORIENTATIONS.has(options.orientation)) {
|
|
210
|
+
throw new CalculationBreakdownError('INVALID_GROUP_ORIENTATION', `Unsupported group orientation: ${String(options.orientation)}`);
|
|
211
|
+
}
|
|
212
|
+
return deepFreeze({
|
|
213
|
+
type: 'group',
|
|
214
|
+
expression,
|
|
215
|
+
...(options?.wrap === true ? { wrap: true } : {}),
|
|
216
|
+
...(options?.semantic !== undefined
|
|
217
|
+
? { semantic: options.semantic }
|
|
218
|
+
: {}),
|
|
219
|
+
...(options?.result !== undefined
|
|
220
|
+
? { result: options.result }
|
|
221
|
+
: {}),
|
|
222
|
+
...(options?.role !== undefined
|
|
223
|
+
? { role: options.role }
|
|
224
|
+
: {}),
|
|
225
|
+
...(options?.orientation !== undefined
|
|
226
|
+
? { orientation: options.orientation }
|
|
227
|
+
: {}),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
createOperation(left, operator, right) {
|
|
231
|
+
const normalizedOperator = OPERATOR_ALIASES[operator] ?? operator;
|
|
232
|
+
if (!OPERATORS.has(normalizedOperator)) {
|
|
233
|
+
throw new CalculationBreakdownError('INVALID_OPERATOR', `Unsupported operator: ${operator}`);
|
|
234
|
+
}
|
|
235
|
+
return deepFreeze({
|
|
236
|
+
type: 'operation',
|
|
237
|
+
operator: normalizedOperator,
|
|
238
|
+
operands: [left, right],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=calculation-breakdown-builder.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const INFIX_OPERATORS = {
|
|
2
|
+
add: '+',
|
|
3
|
+
subtract: '−',
|
|
4
|
+
multiply: '×',
|
|
5
|
+
divide: '÷',
|
|
6
|
+
};
|
|
7
|
+
function localizeDecimal(value, locale) {
|
|
8
|
+
return locale === 'cs' ? value.replace('.', ',') : value;
|
|
9
|
+
}
|
|
10
|
+
function decimalToPercent(value) {
|
|
11
|
+
const negative = value.startsWith('-');
|
|
12
|
+
const unsigned = negative ? value.slice(1) : value;
|
|
13
|
+
const [whole = '0', fraction = ''] = unsigned.split('.');
|
|
14
|
+
const digits = `${whole}${fraction}`;
|
|
15
|
+
const decimalIndex = whole.length + 2;
|
|
16
|
+
const padded = digits.padEnd(decimalIndex, '0');
|
|
17
|
+
const integer = padded
|
|
18
|
+
.slice(0, decimalIndex)
|
|
19
|
+
.replace(/^0+(?=\d)/, '');
|
|
20
|
+
const decimals = padded.slice(decimalIndex).replace(/0+$/, '');
|
|
21
|
+
return `${negative ? '-' : ''}${integer}${decimals ? `.${decimals}` : ''}`;
|
|
22
|
+
}
|
|
23
|
+
function localizeDurationUnit(amount, unit, locale) {
|
|
24
|
+
const singularUnit = unit.slice(0, -1);
|
|
25
|
+
const unitPart = new Intl.NumberFormat(locale, {
|
|
26
|
+
style: 'unit',
|
|
27
|
+
unit: singularUnit,
|
|
28
|
+
unitDisplay: 'long',
|
|
29
|
+
useGrouping: false,
|
|
30
|
+
})
|
|
31
|
+
.formatToParts(Number(amount))
|
|
32
|
+
.find((part) => part.type === 'unit');
|
|
33
|
+
if (!unitPart) {
|
|
34
|
+
throw new Error(`Unable to localize duration unit: ${unit}`);
|
|
35
|
+
}
|
|
36
|
+
return unitPart.value;
|
|
37
|
+
}
|
|
38
|
+
function labelValue(value, semantic, options) {
|
|
39
|
+
if (options.includeNames === false)
|
|
40
|
+
return value;
|
|
41
|
+
const entry = options.dictionary[options.locale]?.[semantic];
|
|
42
|
+
const label = typeof entry === 'string' ? entry : (entry?.name ?? semantic);
|
|
43
|
+
return `${value} ${label}`;
|
|
44
|
+
}
|
|
45
|
+
function renderValue(value, options) {
|
|
46
|
+
const rawDecimal = value.type === 'number' || value.type === 'percent'
|
|
47
|
+
? value.value
|
|
48
|
+
: value.amount;
|
|
49
|
+
const decimal = localizeDecimal(rawDecimal, options.locale);
|
|
50
|
+
if (value.type === 'money') {
|
|
51
|
+
return labelValue(`${decimal} ${value.currency}`, value.semantic, options);
|
|
52
|
+
}
|
|
53
|
+
if (value.type === 'duration') {
|
|
54
|
+
return labelValue(`${decimal} ${localizeDurationUnit(value.amount, value.unit, options.locale)}`, value.semantic, options);
|
|
55
|
+
}
|
|
56
|
+
if (value.type === 'percent') {
|
|
57
|
+
return labelValue(`${localizeDecimal(decimalToPercent(value.value), options.locale)}%`, value.semantic, options);
|
|
58
|
+
}
|
|
59
|
+
return labelValue(decimal, value.semantic, options);
|
|
60
|
+
}
|
|
61
|
+
function renderNode(node, options, nested = false) {
|
|
62
|
+
if (node.type === 'constant') {
|
|
63
|
+
return localizeDecimal(node.value, options.locale);
|
|
64
|
+
}
|
|
65
|
+
if (node.type === 'group') {
|
|
66
|
+
const result = node.result
|
|
67
|
+
? ` = ${renderValue(node.result, options)}`
|
|
68
|
+
: '';
|
|
69
|
+
return `(${renderNode(node.expression, options)}${result})`;
|
|
70
|
+
}
|
|
71
|
+
if (node.type !== 'operation') {
|
|
72
|
+
return renderValue(node, options);
|
|
73
|
+
}
|
|
74
|
+
const [left, right] = node.operands;
|
|
75
|
+
if (node.operator === 'minimum' || node.operator === 'maximum') {
|
|
76
|
+
const functionName = node.operator === 'minimum' ? 'min' : 'max';
|
|
77
|
+
return `${functionName}(${renderNode(left, options)}, ${renderNode(right, options)})`;
|
|
78
|
+
}
|
|
79
|
+
const rendered = `${renderNode(left, options, true)} ${INFIX_OPERATORS[node.operator]} ${renderNode(right, options, true)}`;
|
|
80
|
+
return nested ? `(${rendered})` : rendered;
|
|
81
|
+
}
|
|
82
|
+
/** Renders a plain calculation breakdown without changing its serialized form. */
|
|
83
|
+
export function toHumanString(calculation, options) {
|
|
84
|
+
return `${renderNode(calculation.breakdown, options)} = ${renderValue(calculation.result, options)}`;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=calculation-breakdown-human-string.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Error thrown when a calculation breakdown contains invalid or incomplete input. */
|
|
2
|
+
export class CalculationBreakdownError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
/**
|
|
5
|
+
* Creates a calculation breakdown error.
|
|
6
|
+
*
|
|
7
|
+
* @param code - Stable code suitable for programmatic error handling.
|
|
8
|
+
* @param message - Human-readable explanation of the failure.
|
|
9
|
+
*/
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'CalculationBreakdownError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=calculation-breakdown.error.js.map
|
package/docs/changelog.md
CHANGED
|
@@ -8,6 +8,12 @@ title: Changelog
|
|
|
8
8
|
|
|
9
9
|
Notable changes, starting with 0.1.0. The latest five releases are detailed here; older releases are archived.
|
|
10
10
|
|
|
11
|
+
## 0.1.2 — 2026-10-26
|
|
12
|
+
|
|
13
|
+
- Added an opt-in locale mode to the playground that preserves calculation edits, keeps the default example limited to the calculation, and falls back to semantic keys without generated dictionary placeholders.
|
|
14
|
+
- Made the playground vertically scrollable and its three panels usable on mobile screens.
|
|
15
|
+
- Fixed the deployed playground by including every browser ESM module, and kept release artifact inspection JSON-safe by suppressing duplicate package lifecycle scripts.
|
|
16
|
+
|
|
11
17
|
## 0.1.1 — 2026-10-26
|
|
12
18
|
|
|
13
19
|
- Added the guarded npm, Git tag, release-PR, and GitHub Pages deployment workflow used by `payload-vars`.
|
package/docs/index.md
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
<span class="badge">playground</span>
|
|
18
18
|
</div>
|
|
19
19
|
<div class="actions">
|
|
20
|
+
<label class="option-control">
|
|
21
|
+
<input id="use-locale" type="checkbox" />
|
|
22
|
+
<span>Use locale</span>
|
|
23
|
+
</label>
|
|
20
24
|
<button id="reset" class="button button-secondary" type="button">
|
|
21
25
|
Reset
|
|
22
26
|
</button>
|
|
@@ -67,6 +67,22 @@ body {
|
|
|
67
67
|
|
|
68
68
|
.locale-control { color: var(--muted); font-size: 12px; }
|
|
69
69
|
|
|
70
|
+
.option-control {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 7px;
|
|
74
|
+
color: var(--muted);
|
|
75
|
+
font-size: 12px;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.option-control input {
|
|
80
|
+
width: 15px;
|
|
81
|
+
height: 15px;
|
|
82
|
+
margin: 0;
|
|
83
|
+
accent-color: var(--accent);
|
|
84
|
+
}
|
|
85
|
+
|
|
70
86
|
select,
|
|
71
87
|
.button {
|
|
72
88
|
height: 36px;
|
|
@@ -274,6 +290,8 @@ textarea:focus { box-shadow: inset 0 0 0 1px #596927; }
|
|
|
274
290
|
background: #e9ecdf;
|
|
275
291
|
}
|
|
276
292
|
|
|
293
|
+
.language-switcher[hidden] { display: none; }
|
|
294
|
+
|
|
277
295
|
.view-switcher {
|
|
278
296
|
grid-template-columns: repeat(3, 1fr);
|
|
279
297
|
}
|
|
@@ -363,12 +381,27 @@ textarea:focus { box-shadow: inset 0 0 0 1px #596927; }
|
|
|
363
381
|
}
|
|
364
382
|
|
|
365
383
|
@media (max-width: 650px) {
|
|
384
|
+
html,
|
|
385
|
+
body {
|
|
386
|
+
height: auto;
|
|
387
|
+
min-height: 100%;
|
|
388
|
+
overflow-x: hidden;
|
|
389
|
+
overflow-y: auto;
|
|
390
|
+
-webkit-overflow-scrolling: touch;
|
|
391
|
+
}
|
|
392
|
+
|
|
366
393
|
.topbar { align-items: flex-start; flex-direction: column; }
|
|
367
394
|
.actions { width: 100%; flex-wrap: wrap; }
|
|
368
395
|
.workspace {
|
|
396
|
+
flex: none;
|
|
369
397
|
grid-template-columns: 1fr;
|
|
370
|
-
grid-template-rows:
|
|
398
|
+
grid-template-rows: none;
|
|
399
|
+
grid-auto-rows: max(560px, 80dvh);
|
|
371
400
|
}
|
|
372
401
|
.preview-panel { grid-column: auto; }
|
|
373
|
-
.panel {
|
|
402
|
+
.panel {
|
|
403
|
+
min-height: 560px;
|
|
404
|
+
border-right: 0;
|
|
405
|
+
border-bottom: 1px solid var(--line);
|
|
406
|
+
}
|
|
374
407
|
}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
toHumanString,
|
|
4
4
|
} from '../assets/calculation-breakdown/index.js';
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const DEFAULT_BUILDER_WITH_LOCALE = `// Derive calendar-day counts in UTC to avoid daylight-saving offsets.
|
|
7
7
|
const helper = {
|
|
8
8
|
now: new Date(),
|
|
9
9
|
get year() { return this.now.getFullYear(); },
|
|
@@ -179,6 +179,35 @@ return {
|
|
|
179
179
|
dictionary, // Optional: comment out this line to use semantic keys directly.
|
|
180
180
|
};`;
|
|
181
181
|
|
|
182
|
+
const localeMetadataStart = DEFAULT_BUILDER_WITH_LOCALE.indexOf(
|
|
183
|
+
'\n// Optional localized title',
|
|
184
|
+
);
|
|
185
|
+
const DEFAULT_BUILDER_CORE = DEFAULT_BUILDER_WITH_LOCALE.slice(
|
|
186
|
+
0,
|
|
187
|
+
localeMetadataStart,
|
|
188
|
+
);
|
|
189
|
+
const DEFAULT_LOCALE_METADATA = DEFAULT_BUILDER_WITH_LOCALE.slice(
|
|
190
|
+
localeMetadataStart,
|
|
191
|
+
);
|
|
192
|
+
const DEFAULT_MINIMAL_EXPORT = `
|
|
193
|
+
|
|
194
|
+
// Export only the calculation; renderers fall back to its semantic keys.
|
|
195
|
+
return { calculation: invoice };`;
|
|
196
|
+
const DEFAULT_BUILDER = `${DEFAULT_BUILDER_CORE}${DEFAULT_MINIMAL_EXPORT}`;
|
|
197
|
+
|
|
198
|
+
function setBuilderLocaleMode(source, useLocale) {
|
|
199
|
+
const localizedStart = source.indexOf('\n// Optional localized title');
|
|
200
|
+
const hasMinimalExport = source.endsWith(DEFAULT_MINIMAL_EXPORT);
|
|
201
|
+
const core = localizedStart >= 0
|
|
202
|
+
? source.slice(0, localizedStart)
|
|
203
|
+
: hasMinimalExport
|
|
204
|
+
? source.slice(0, -DEFAULT_MINIMAL_EXPORT.length)
|
|
205
|
+
: source;
|
|
206
|
+
|
|
207
|
+
if (localizedStart < 0 && !hasMinimalExport) return source;
|
|
208
|
+
return `${core}${useLocale ? DEFAULT_LOCALE_METADATA : DEFAULT_MINIMAL_EXPORT}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
182
211
|
const DEFAULT_CSS = `.calculation-card__expression {
|
|
183
212
|
font: 650 14px/1.45 Inter, system-ui, sans-serif;
|
|
184
213
|
white-space: nowrap;
|
|
@@ -325,6 +354,7 @@ const elements = {
|
|
|
325
354
|
status: document.querySelector('#status'),
|
|
326
355
|
run: document.querySelector('#run'),
|
|
327
356
|
reset: document.querySelector('#reset'),
|
|
357
|
+
useLocale: document.querySelector('#use-locale'),
|
|
328
358
|
};
|
|
329
359
|
|
|
330
360
|
let updateTimer;
|
|
@@ -1114,10 +1144,16 @@ function render() {
|
|
|
1114
1144
|
hideTooltip();
|
|
1115
1145
|
const output = evaluateBuilder(elements.builder.value);
|
|
1116
1146
|
const locale = selectedLocale;
|
|
1117
|
-
const
|
|
1147
|
+
const useLocale = elements.useLocale.checked;
|
|
1148
|
+
const dictionary = useLocale ? output.dictionary ?? {} : {};
|
|
1149
|
+
const hasLocalization = useLocale && Boolean(
|
|
1150
|
+
output.title || output.dictionary,
|
|
1151
|
+
);
|
|
1118
1152
|
const examples = output.examples ?? [
|
|
1119
1153
|
{
|
|
1120
|
-
title:
|
|
1154
|
+
title: useLocale
|
|
1155
|
+
? output.title ?? { en: 'Calculation', cs: 'Výpočet' }
|
|
1156
|
+
: undefined,
|
|
1121
1157
|
calculation: output.calculation,
|
|
1122
1158
|
},
|
|
1123
1159
|
];
|
|
@@ -1128,6 +1164,10 @@ function render() {
|
|
|
1128
1164
|
const fragment = elements.template.content.cloneNode(true);
|
|
1129
1165
|
const calculation = example.calculation;
|
|
1130
1166
|
const entries = createSemanticEntries(output, calculation, locale);
|
|
1167
|
+
const languageSwitcher = fragment.querySelector(
|
|
1168
|
+
'.language-switcher',
|
|
1169
|
+
);
|
|
1170
|
+
languageSwitcher.hidden = !hasLocalization;
|
|
1131
1171
|
|
|
1132
1172
|
for (const button of fragment.querySelectorAll('[data-locale]')) {
|
|
1133
1173
|
const isActive = button.dataset.locale === locale;
|
|
@@ -1170,7 +1210,9 @@ function render() {
|
|
|
1170
1210
|
fragment.querySelector('[data-result-type]').textContent =
|
|
1171
1211
|
calculation.result.type;
|
|
1172
1212
|
const includeNames = selectedView === 'formula-names';
|
|
1173
|
-
const command =
|
|
1213
|
+
const command = useLocale
|
|
1214
|
+
? `toHumanString(calculation, { locale: '${locale}', dictionary, includeNames: ${includeNames} })`
|
|
1215
|
+
: `toHumanString(calculation, { includeNames: ${includeNames} })`;
|
|
1174
1216
|
const commandElement = fragment.querySelector('[data-command]');
|
|
1175
1217
|
commandElement.hidden = selectedView === 'table';
|
|
1176
1218
|
commandElement.innerHTML = highlight(
|
|
@@ -1214,6 +1256,7 @@ function scheduleRender() {
|
|
|
1214
1256
|
}
|
|
1215
1257
|
|
|
1216
1258
|
function reset() {
|
|
1259
|
+
elements.useLocale.checked = false;
|
|
1217
1260
|
elements.builder.value = DEFAULT_BUILDER;
|
|
1218
1261
|
elements.css.value = DEFAULT_CSS;
|
|
1219
1262
|
selectedLocale = 'en';
|
|
@@ -1245,6 +1288,15 @@ for (const editor of [elements.builder, elements.css]) {
|
|
|
1245
1288
|
|
|
1246
1289
|
elements.run.addEventListener('click', render);
|
|
1247
1290
|
elements.reset.addEventListener('click', reset);
|
|
1291
|
+
elements.useLocale.addEventListener('change', () => {
|
|
1292
|
+
selectedLocale = 'en';
|
|
1293
|
+
elements.builder.value = setBuilderLocaleMode(
|
|
1294
|
+
elements.builder.value,
|
|
1295
|
+
elements.useLocale.checked,
|
|
1296
|
+
);
|
|
1297
|
+
updateHighlights();
|
|
1298
|
+
render();
|
|
1299
|
+
});
|
|
1248
1300
|
elements.preview.addEventListener('scroll', hideTooltip, { passive: true });
|
|
1249
1301
|
window.addEventListener('resize', hideTooltip, { passive: true });
|
|
1250
1302
|
|