Skip to content

Commit ded2199

Browse files
authored
Merge pull request #4 from oddbit/ui/delta-thousands-separators
ui: thousands separators on delta percentages
2 parents 349b3f3 + 508ec18 commit ded2199

11 files changed

Lines changed: 143 additions & 26 deletions

File tree

LICENSE

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"Contribution" shall mean any work of authorship, including
5050
the original version of the Work and any modifications or additions
5151
to that Work or Derivative Works thereof, that is intentionally
52-
submitted to the Licensor for inclusion in the Work by the copyright owner
52+
submitted to Licensor for inclusion in the Work by the copyright owner
5353
or by an individual or Legal Entity authorized to submit on behalf of
5454
the copyright owner. For the purposes of this definition, "submitted"
5555
means any form of electronic, verbal, or written communication sent
@@ -61,7 +61,7 @@
6161
designated in writing by the copyright owner as "Not a Contribution."
6262

6363
"Contributor" shall mean Licensor and any individual or Legal Entity
64-
on behalf of whom a Contribution has been received by the Licensor and
64+
on behalf of whom a Contribution has been received by Licensor and
6565
subsequently incorporated within the Work.
6666

6767
2. Grant of Copyright License. Subject to the terms and conditions of
@@ -107,7 +107,7 @@
107107
(d) If the Work includes a "NOTICE" text file as part of its
108108
distribution, then any Derivative Works that You distribute must
109109
include a readable copy of the attribution notices contained
110-
within such NOTICE file, excluding any notices that do not
110+
within such NOTICE file, excluding those notices that do not
111111
pertain to any part of the Derivative Works, in at least one
112112
of the following places: within a NOTICE text file distributed
113113
as part of the Derivative Works; within the Source form or
@@ -176,7 +176,18 @@
176176

177177
END OF TERMS AND CONDITIONS
178178

179-
Copyright 2026 Oddbit (https://oddbit.id)
179+
APPENDIX: How to apply the Apache License to your work.
180+
181+
To apply the Apache License to your work, attach the following
182+
boilerplate notice, with the fields enclosed by brackets "[]"
183+
replaced with your own identifying information. (Don't include
184+
the brackets!) The text should be enclosed in the appropriate
185+
comment syntax for the file format. We also recommend that a
186+
file or class name and description of purpose be included on the
187+
same "printed page" as the copyright notice for easier
188+
identification within third-party archives.
189+
190+
Copyright [yyyy] [name of copyright owner]
180191

181192
Licensed under the Apache License, Version 2.0 (the "License");
182193
you may not use this file except in compliance with the License.

sdk/dart/LICENSE

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"Contribution" shall mean any work of authorship, including
5050
the original version of the Work and any modifications or additions
5151
to that Work or Derivative Works thereof, that is intentionally
52-
submitted to the Licensor for inclusion in the Work by the copyright owner
52+
submitted to Licensor for inclusion in the Work by the copyright owner
5353
or by an individual or Legal Entity authorized to submit on behalf of
5454
the copyright owner. For the purposes of this definition, "submitted"
5555
means any form of electronic, verbal, or written communication sent
@@ -61,7 +61,7 @@
6161
designated in writing by the copyright owner as "Not a Contribution."
6262

6363
"Contributor" shall mean Licensor and any individual or Legal Entity
64-
on behalf of whom a Contribution has been received by the Licensor and
64+
on behalf of whom a Contribution has been received by Licensor and
6565
subsequently incorporated within the Work.
6666

6767
2. Grant of Copyright License. Subject to the terms and conditions of
@@ -107,7 +107,7 @@
107107
(d) If the Work includes a "NOTICE" text file as part of its
108108
distribution, then any Derivative Works that You distribute must
109109
include a readable copy of the attribution notices contained
110-
within such NOTICE file, excluding any notices that do not
110+
within such NOTICE file, excluding those notices that do not
111111
pertain to any part of the Derivative Works, in at least one
112112
of the following places: within a NOTICE text file distributed
113113
as part of the Derivative Works; within the Source form or
@@ -176,7 +176,18 @@
176176

177177
END OF TERMS AND CONDITIONS
178178

179-
Copyright 2026 Oddbit (https://oddbit.id)
179+
APPENDIX: How to apply the Apache License to your work.
180+
181+
To apply the Apache License to your work, attach the following
182+
boilerplate notice, with the fields enclosed by brackets "[]"
183+
replaced with your own identifying information. (Don't include
184+
the brackets!) The text should be enclosed in the appropriate
185+
comment syntax for the file format. We also recommend that a
186+
file or class name and description of purpose be included on the
187+
same "printed page" as the copyright notice for easier
188+
identification within third-party archives.
189+
190+
Copyright [yyyy] [name of copyright owner]
180191

181192
Licensed under the Apache License, Version 2.0 (the "License");
182193
you may not use this file except in compliance with the License.

sdk/python/LICENSE

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"Contribution" shall mean any work of authorship, including
5050
the original version of the Work and any modifications or additions
5151
to that Work or Derivative Works thereof, that is intentionally
52-
submitted to the Licensor for inclusion in the Work by the copyright owner
52+
submitted to Licensor for inclusion in the Work by the copyright owner
5353
or by an individual or Legal Entity authorized to submit on behalf of
5454
the copyright owner. For the purposes of this definition, "submitted"
5555
means any form of electronic, verbal, or written communication sent
@@ -61,7 +61,7 @@
6161
designated in writing by the copyright owner as "Not a Contribution."
6262

6363
"Contributor" shall mean Licensor and any individual or Legal Entity
64-
on behalf of whom a Contribution has been received by the Licensor and
64+
on behalf of whom a Contribution has been received by Licensor and
6565
subsequently incorporated within the Work.
6666

6767
2. Grant of Copyright License. Subject to the terms and conditions of
@@ -107,7 +107,7 @@
107107
(d) If the Work includes a "NOTICE" text file as part of its
108108
distribution, then any Derivative Works that You distribute must
109109
include a readable copy of the attribution notices contained
110-
within such NOTICE file, excluding any notices that do not
110+
within such NOTICE file, excluding those notices that do not
111111
pertain to any part of the Derivative Works, in at least one
112112
of the following places: within a NOTICE text file distributed
113113
as part of the Derivative Works; within the Source form or
@@ -176,7 +176,18 @@
176176

177177
END OF TERMS AND CONDITIONS
178178

179-
Copyright 2026 Oddbit (https://oddbit.id)
179+
APPENDIX: How to apply the Apache License to your work.
180+
181+
To apply the Apache License to your work, attach the following
182+
boilerplate notice, with the fields enclosed by brackets "[]"
183+
replaced with your own identifying information. (Don't include
184+
the brackets!) The text should be enclosed in the appropriate
185+
comment syntax for the file format. We also recommend that a
186+
file or class name and description of purpose be included on the
187+
same "printed page" as the copyright notice for easier
188+
identification within third-party archives.
189+
190+
Copyright [yyyy] [name of copyright owner]
180191

181192
Licensed under the Apache License, Version 2.0 (the "License");
182193
you may not use this file except in compliance with the License.

sdk/typescript/LICENSE

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"Contribution" shall mean any work of authorship, including
5050
the original version of the Work and any modifications or additions
5151
to that Work or Derivative Works thereof, that is intentionally
52-
submitted to the Licensor for inclusion in the Work by the copyright owner
52+
submitted to Licensor for inclusion in the Work by the copyright owner
5353
or by an individual or Legal Entity authorized to submit on behalf of
5454
the copyright owner. For the purposes of this definition, "submitted"
5555
means any form of electronic, verbal, or written communication sent
@@ -61,7 +61,7 @@
6161
designated in writing by the copyright owner as "Not a Contribution."
6262

6363
"Contributor" shall mean Licensor and any individual or Legal Entity
64-
on behalf of whom a Contribution has been received by the Licensor and
64+
on behalf of whom a Contribution has been received by Licensor and
6565
subsequently incorporated within the Work.
6666

6767
2. Grant of Copyright License. Subject to the terms and conditions of
@@ -107,7 +107,7 @@
107107
(d) If the Work includes a "NOTICE" text file as part of its
108108
distribution, then any Derivative Works that You distribute must
109109
include a readable copy of the attribution notices contained
110-
within such NOTICE file, excluding any notices that do not
110+
within such NOTICE file, excluding those notices that do not
111111
pertain to any part of the Derivative Works, in at least one
112112
of the following places: within a NOTICE text file distributed
113113
as part of the Derivative Works; within the Source form or
@@ -176,7 +176,18 @@
176176

177177
END OF TERMS AND CONDITIONS
178178

179-
Copyright 2026 Oddbit (https://oddbit.id)
179+
APPENDIX: How to apply the Apache License to your work.
180+
181+
To apply the Apache License to your work, attach the following
182+
boilerplate notice, with the fields enclosed by brackets "[]"
183+
replaced with your own identifying information. (Don't include
184+
the brackets!) The text should be enclosed in the appropriate
185+
comment syntax for the file format. We also recommend that a
186+
file or class name and description of purpose be included on the
187+
same "printed page" as the copyright notice for easier
188+
identification within third-party archives.
189+
190+
Copyright [yyyy] [name of copyright owner]
180191

181192
Licensed under the Apache License, Version 2.0 (the "License");
182193
you may not use this file except in compliance with the License.

src/__tests__/page/links-page.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { SELF, env } from "cloudflare:test";
66
import { LinkRepository, SlugRepository } from "../../db";
77
import { applyMigrations, resetData } from "../setup";
88

9-
function req(path: string): Request {
10-
return new Request(`https://shrtnr.test${path}`);
9+
function req(path: string, init?: RequestInit): Request {
10+
return new Request(`https://shrtnr.test${path}`, init);
1111
}
1212

1313
beforeAll(applyMigrations);
@@ -133,6 +133,27 @@ describe("Links listing page", () => {
133133
expect(html).toMatch(/<td[^>]*class="[^"]*col-date[^"]*"[^>]*>[\s\S]*?class="delta /);
134134
});
135135

136+
it("delta pct of 4+ digits uses locale thousands separators", async () => {
137+
const link = await LinkRepository.create(env.DB, {
138+
url: "https://example.com",
139+
slug: "abc",
140+
});
141+
const now = Math.floor(Date.now() / 1000);
142+
// 1 click in the previous 30d window, 15 clicks in the current → pct = 1400
143+
const insertClick = env.DB.prepare("INSERT INTO clicks (slug, clicked_at) VALUES (?, ?)");
144+
await insertClick.bind(link.slugs[0].slug, now - 40 * 86400).run();
145+
for (let i = 0; i < 15; i++) {
146+
await insertClick.bind(link.slugs[0].slug, now - 60 - i).run();
147+
}
148+
149+
// Pin lang=en so the comma-grouping assertion is deterministic regardless of
150+
// future default-locale changes.
151+
const res = await SELF.fetch(req("/_/admin/links", { headers: { Cookie: "lang=en" } }));
152+
const html = await res.text();
153+
expect(html).toMatch(/class="delta-label">\+1,400%</);
154+
expect(html).not.toMatch(/class="delta-label">\+1400%</);
155+
});
156+
136157
it("pagination shows a '1–N of Total' summary", async () => {
137158
for (let i = 0; i < 30; i++) {
138159
await LinkRepository.create(env.DB, {

src/__tests__/unit/delta.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright 2026 Oddbit (https://oddbit.id)
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it } from "vitest";
5+
import { jsx } from "hono/jsx";
6+
import { Delta } from "../../components/delta";
7+
8+
function render(props: { pct: number; lang: string }): string {
9+
return String(jsx(Delta, props));
10+
}
11+
12+
describe("Delta component", () => {
13+
it("renders +1,400% for an en delta of 1400", () => {
14+
const out = render({ pct: 1400, lang: "en" });
15+
expect(out).toContain("+1,400%");
16+
expect(out).toContain("delta up");
17+
expect(out).toContain("trending_up");
18+
});
19+
20+
it("normalizes -0 to 0 so the flat delta does not render -0%", () => {
21+
// Math.round(((99.9 - 100) / 100) * 100) === -0, which Intl.NumberFormat
22+
// would otherwise render as "-0".
23+
const out = render({ pct: -0, lang: "en" });
24+
expect(out).toContain(">0%<");
25+
expect(out).not.toContain("-0%");
26+
expect(out).not.toContain("−0%");
27+
expect(out).toContain("delta flat");
28+
expect(out).toContain("trending_flat");
29+
});
30+
31+
it("groups thousands using the active locale", () => {
32+
expect(render({ pct: 1400, lang: "id" })).toContain("+1.400%");
33+
});
34+
35+
it("renders a negative delta with a minus and the down direction", () => {
36+
const out = render({ pct: -25, lang: "en" });
37+
expect(out).toContain("-25%");
38+
expect(out).toContain("delta down");
39+
expect(out).toContain("trending_down");
40+
});
41+
});

src/components/delta.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import type { FC } from "hono/jsx";
5+
import { fmtNumber } from "../i18n/format";
56

67
type DeltaProps = {
78
pct: number;
9+
lang: string;
810
id?: string;
911
};
1012

11-
export const Delta: FC<DeltaProps> = ({ pct, id }) => {
12-
const dir = pct > 0 ? "up" : pct < 0 ? "down" : "flat";
13+
export const Delta: FC<DeltaProps> = ({ pct, lang, id }) => {
14+
// Normalize -0 to 0 so Intl.NumberFormat does not render a confusing "-0%"
15+
// when dir is "flat".
16+
const safePct = Object.is(pct, -0) ? 0 : pct;
17+
const dir = safePct > 0 ? "up" : safePct < 0 ? "down" : "flat";
1318
const icon = dir === "up" ? "trending_up" : dir === "down" ? "trending_down" : "trending_flat";
14-
const sign = pct > 0 ? "+" : "";
19+
const sign = safePct > 0 ? "+" : "";
1520
return (
16-
<span class={`delta ${dir}`} id={id} data-delta={String(pct)}>
21+
<span class={`delta ${dir}`} id={id} data-delta={String(safePct)}>
1722
<span class="icon">{icon}</span>
18-
<span class="delta-label">{sign}{pct}%</span>
23+
<span class="delta-label">{sign}{fmtNumber(safePct, lang)}%</span>
1924
</span>
2025
);
2126
};

src/components/kpi-card.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type KpiCardProps = {
1313
valueId?: string;
1414
deltaPct?: number | null;
1515
deltaId?: string;
16+
lang: string;
1617
hint?: string;
1718
sparkline?: number[];
1819
span?: 1 | 2 | 3;
@@ -26,6 +27,7 @@ export const KpiCard: FC<KpiCardProps> = ({
2627
valueId,
2728
deltaPct,
2829
deltaId,
30+
lang,
2931
hint,
3032
sparkline,
3133
span = 1,
@@ -38,7 +40,7 @@ export const KpiCard: FC<KpiCardProps> = ({
3840
{icon && <span class="icon">{icon}</span>}
3941
<span>{label}</span>
4042
</div>
41-
{deltaPct !== undefined && deltaPct !== null && <Delta pct={deltaPct} id={deltaId} />}
43+
{deltaPct !== undefined && deltaPct !== null && <Delta pct={deltaPct} lang={lang} id={deltaId} />}
4244
</div>
4345
<div class="kpi-value" id={valueId}>{value}</div>
4446
{hint && <div class="kpi-hint">{hint}</div>}

src/pages/bundles.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export const BundlesPage: FC<Props> = ({ bundles, t, lang, filter, range }) => {
7676
{b.archived_at && <span class="bundle-archived-badge">{t("bundles.archived")}</span>}
7777
{b.delta_pct !== undefined && (
7878
<span class="bundle-card-delta">
79-
<Delta pct={b.delta_pct} />
79+
<Delta pct={b.delta_pct} lang={lang} />
8080
</span>
8181
)}
8282
</div>

src/pages/dashboard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const DashboardPage: FC<Props> = ({ stats, t, lang, range }) => {
8787
valueId="dash-total-links"
8888
deltaPct={d.new_links_delta}
8989
deltaId="dash-links-delta"
90+
lang={lang}
9091
sparkline={d.timeline_links}
9192
/>
9293
<KpiCard
@@ -97,6 +98,7 @@ export const DashboardPage: FC<Props> = ({ stats, t, lang, range }) => {
9798
valueId="dash-clicked-links"
9899
deltaPct={d.clicked_links_delta}
99100
deltaId="dash-clicked-links-delta"
101+
lang={lang}
100102
sparkline={d.timeline_clicked_links}
101103
/>
102104
<KpiCard
@@ -107,6 +109,7 @@ export const DashboardPage: FC<Props> = ({ stats, t, lang, range }) => {
107109
valueId="dash-total-clicks"
108110
deltaPct={d.total_clicks_delta}
109111
deltaId="dash-clicks-delta"
112+
lang={lang}
110113
sparkline={d.timeline}
111114
/>
112115
<KpiCard
@@ -117,6 +120,7 @@ export const DashboardPage: FC<Props> = ({ stats, t, lang, range }) => {
117120
valueId="dash-clicks-per-day"
118121
deltaPct={d.clicks_per_day_delta}
119122
deltaId="dash-clicks-per-day-delta"
123+
lang={lang}
120124
sparkline={d.timeline}
121125
/>
122126
</div>

0 commit comments

Comments
 (0)