Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/zhhant-conversion-rate-analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'hotcrm': patch
---

The two analytics pages that still said 「轉換率」 in Traditional Chinese now say
「轉化率」, matching their Simplified twins line for line.

`#844` swept the lead **convert** verb to 转化 / 轉化 across every Chinese page,
but its "not part of this issue" list excluded the analytics **conversion rate**
phrase on the grounds that it is a metric name rather than the convert verb. On
the baseline that reason does not hold:

- **No Simplified page anywhere says 转换率.** The Simplified twins of both
lines already read 转化率 — `content/docs/analytics/cubes.zh-Hans.mdx:124` and
`content/docs/analytics/reports.zh-Hans.mdx:32` — so the exclusion did not
protect a metric spelling, it just left the Traditional side behind.
- **Conversion rate has no label surface to be a metric name against.** The
phrase appears nowhere under `src/`; it is prose each page translates for
itself, so there is no locale-pack entry giving it a word contract independent
of the convert verb. The nearest anchors the pack does provide both follow
转化: `crm_forecast.num_converted_leads.label` is 「已转化线索数」 and the
dashboard's `open_leads.title` is 「未转化线索」
(`src/translations/zh-CN.ts`).
- **The exclusion left each page contradicting itself.**
`content/docs/analytics/cubes.zh-Hant.mdx` said 「轉化數量」 at `:108` and
「轉換率」 sixteen lines later; `content/docs/analytics/reports.zh-Hant.mdx`
said 「轉換率」 in one table row at `:32` and 「轉化」 in another at `:59` —
both halves fixed by #844 — which is exactly the half-right page #801 objected
to.

`test/docs-conversion-rate-spelling.test.ts` keeps it from drifting back. No
locale-pack key exists for the phrase, so the check lives where the claim does —
it sweeps every Chinese page for the rejected spelling and, separately, asserts
the two pages still state the metric, so deleting the sentence fails as loudly as
mis-spelling it.

Two lines changed, both Traditional. State-transition 转换 / 轉換, data-type
coercion and contract activation keep their spelling everywhere — they are a
different word that happens to share the old convert translation, and #844's
reading of that boundary is unchanged. English and Simplified pages were already
right, and nothing under `src/` changed.

Fixes #905.
2 changes: 1 addition & 1 deletion content/docs/analytics/cubes.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ description: 為 HotCRM 分析提供支援的資料層——四個內建多維
**範例問題**:
- *"按行銷活動類型的 ROI。"*
- *"按通路的每商機成本。"*
- *"按人物誌的轉換率。"*
- *"按人物誌的轉化率。"*

## 使用者如何與多維資料集互動

Expand Down
2 changes: 1 addition & 1 deletion content/docs/analytics/reports.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ HotCRM 開箱即用地提供以下報表:
| 報表 | 顯示內容 |
| --- | --- |
| **Lead Conversion Funnel** | New → Working → Qualified → Converted |
| **Lead Source ROI** | 按來源的數量、轉換率和收入 |
| **Lead Source ROI** | 按來源的數量、轉化率和收入 |
| **Aged Leads** | 處於 *New* 或 *Working* 狀態超過 30 天的潛在客戶 |

### 🎧 服務報表
Expand Down
122 changes: 122 additions & 0 deletions test/docs-conversion-rate-spelling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { REPO_ROOT } from './helpers/repo-root';

/**
* "Conversion rate" is spelled 转化率 / 轉化率 on every Chinese page (#905).
*
* ## Why this needed a guard rather than a one-time sweep
*
* #801 / #825 / #829 established the convert verb face: the locale pack is the
* contract, `convert_lead.label` is 转化线索 and `is_converted` is 已转化, so the
* docs say 转化. #844 then swept 转换 -> 转化 across the Chinese pages — but its
* "not part of this issue" list excluded the analytics *conversion rate* phrase,
* reasoning that a metric name is not the convert verb.
*
* Measured on the baseline, that reason did not hold, and #905 is the bill:
*
* - No Simplified page anywhere spelled it 转换率. Both Simplified twins
* already read 转化率, so the exclusion protected no metric spelling — it
* just left the Traditional side behind.
* - The phrase has no locale-pack entry to be a metric name against. It
* appears nowhere under `src/`; it is prose each page translates for itself.
* The nearest anchors the pack does give both follow 转化 —
* `crm_forecast.num_converted_leads.label` is 已转化线索数 and the dashboard's
* `open_leads.title` is 未转化线索.
* - So each Traditional page contradicted itself: `analytics/cubes.zh-Hant`
* said 轉化數量 at one bullet and 轉換率 sixteen lines later;
* `analytics/reports.zh-Hant` said 轉換率 in one table row and 轉化 in
* another — both halves already fixed by #844.
*
* Nothing caught it, because a phrase in an `.mdx` paragraph is prose: `os
* validate` and `pnpm lint` walk authored metadata and never open
* `content/docs`, and no locale-pack key exists for a docs test to derive the
* expected spelling from. The check therefore has to live where the claim lives.
*
* ## The boundary: 转换率, never bare 转换
*
* This guard rejects exactly the three-character compound 转换率 / 轉換率. Bare
* 转换 / 轉換 is deliberately untouched and MUST stay reachable: it names the
* state-machine mechanism (state transition) on
* `content/docs/administration/state-machines`, type coercion in the import
* guides, and contract activation on `content/docs/revenue/contracts` — a
* different word that happens to share a spelling with the retired convert
* translation. #844 drew that line; this test does not move it, and widening
* the pattern to bare 转换 would turn every one of those pages red.
*
* ## Why the negative assertion is not alone
*
* A pure "nobody spells it 转换率" assertion stays green if the sentence is
* simply deleted — passing because nothing is produced, not because the wording
* is right. {@link CARRIERS} pins the other direction: the pages that carry the
* metric must still carry it, in both scripts. Delete the line and this file
* goes red just as loudly as mis-spelling it does.
*/

const DOCS_ROOT = join(REPO_ROOT, 'content/docs');

/** The rejected spelling, both scripts. */
const REJECTED = ['转换率', '轉換率'];

/**
* Pages that carry the conversion-rate metric, and the spelling each must use.
*
* Authored, not derived — there is no locale-pack key to derive it from, which
* is the whole reason this file exists. A new page that states the metric
* belongs here; the negative sweep below already covers it against the wrong
* spelling either way.
*/
const CARRIERS: { page: string; 'zh-Hans': string; 'zh-Hant': string }[] = [
{ page: 'analytics/cubes', 'zh-Hans': '转化率', 'zh-Hant': '轉化率' },
{ page: 'analytics/reports', 'zh-Hans': '转化率', 'zh-Hant': '轉化率' },
];

/** Every Chinese docs page, in either script. */
const chineseDocs = (): string[] => {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.zh-Han[st]\.mdx$/.test(entry.name)) out.push(full);
}
};
walk(DOCS_ROOT);
return out.sort();
};

describe('conversion rate reads 转化率 on every Chinese docs page (#905)', () => {
const pages = chineseDocs();

// Same spirit as `source-hygiene-scan-surface.test.ts`: a sweep that walks an
// empty tree passes without checking anything. If the docs move, fail here
// rather than silently reporting success over nothing.
it('has Chinese docs pages to check', () => {
expect(pages.length).toBeGreaterThan(50);
});

it('no Chinese page spells it 转换率 / 轉換率', () => {
const offenders: string[] = [];
for (const file of pages) {
const text = readFileSync(file, 'utf8');
text.split('\n').forEach((line, i) => {
for (const bad of REJECTED) {
if (line.includes(bad)) {
offenders.push(`${relative(REPO_ROOT, file)}:${i + 1} — ${bad}`);
}
}
});
}
expect(offenders).toEqual([]);
});

it.each(CARRIERS)('$page still states the metric in both scripts', (carrier) => {
for (const locale of ['zh-Hans', 'zh-Hant'] as const) {
const file = join(DOCS_ROOT, `${carrier.page}.${locale}.mdx`);
expect(readFileSync(file, 'utf8')).toContain(carrier[locale]);
}
});
});
Loading