diff --git a/LICENSE b/LICENSE index 8fc4ecb..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -49,7 +49,7 @@ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner + submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent @@ -61,7 +61,7 @@ designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and + on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of @@ -107,7 +107,7 @@ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not + within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or @@ -176,7 +176,18 @@ END OF TERMS AND CONDITIONS - Copyright 2026 Oddbit (https://oddbit.id) + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/sdk/dart/LICENSE b/sdk/dart/LICENSE index 8fc4ecb..d645695 100644 --- a/sdk/dart/LICENSE +++ b/sdk/dart/LICENSE @@ -49,7 +49,7 @@ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner + submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent @@ -61,7 +61,7 @@ designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and + on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of @@ -107,7 +107,7 @@ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not + within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or @@ -176,7 +176,18 @@ END OF TERMS AND CONDITIONS - Copyright 2026 Oddbit (https://oddbit.id) + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/sdk/python/LICENSE b/sdk/python/LICENSE index 8fc4ecb..d645695 100644 --- a/sdk/python/LICENSE +++ b/sdk/python/LICENSE @@ -49,7 +49,7 @@ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner + submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent @@ -61,7 +61,7 @@ designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and + on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of @@ -107,7 +107,7 @@ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not + within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or @@ -176,7 +176,18 @@ END OF TERMS AND CONDITIONS - Copyright 2026 Oddbit (https://oddbit.id) + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/sdk/typescript/LICENSE b/sdk/typescript/LICENSE index 8fc4ecb..d645695 100644 --- a/sdk/typescript/LICENSE +++ b/sdk/typescript/LICENSE @@ -49,7 +49,7 @@ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner + submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent @@ -61,7 +61,7 @@ designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and + on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of @@ -107,7 +107,7 @@ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not + within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or @@ -176,7 +176,18 @@ END OF TERMS AND CONDITIONS - Copyright 2026 Oddbit (https://oddbit.id) + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/__tests__/page/links-page.test.ts b/src/__tests__/page/links-page.test.ts index 290da13..143a621 100644 --- a/src/__tests__/page/links-page.test.ts +++ b/src/__tests__/page/links-page.test.ts @@ -6,8 +6,8 @@ import { SELF, env } from "cloudflare:test"; import { LinkRepository, SlugRepository } from "../../db"; import { applyMigrations, resetData } from "../setup"; -function req(path: string): Request { - return new Request(`https://shrtnr.test${path}`); +function req(path: string, init?: RequestInit): Request { + return new Request(`https://shrtnr.test${path}`, init); } beforeAll(applyMigrations); @@ -133,6 +133,27 @@ describe("Links listing page", () => { expect(html).toMatch(/]*class="[^"]*col-date[^"]*"[^>]*>[\s\S]*?class="delta /); }); + it("delta pct of 4+ digits uses locale thousands separators", async () => { + const link = await LinkRepository.create(env.DB, { + url: "https://example.com", + slug: "abc", + }); + const now = Math.floor(Date.now() / 1000); + // 1 click in the previous 30d window, 15 clicks in the current → pct = 1400 + const insertClick = env.DB.prepare("INSERT INTO clicks (slug, clicked_at) VALUES (?, ?)"); + await insertClick.bind(link.slugs[0].slug, now - 40 * 86400).run(); + for (let i = 0; i < 15; i++) { + await insertClick.bind(link.slugs[0].slug, now - 60 - i).run(); + } + + // Pin lang=en so the comma-grouping assertion is deterministic regardless of + // future default-locale changes. + const res = await SELF.fetch(req("/_/admin/links", { headers: { Cookie: "lang=en" } })); + const html = await res.text(); + expect(html).toMatch(/class="delta-label">\+1,400%\+1400% { for (let i = 0; i < 30; i++) { await LinkRepository.create(env.DB, { diff --git a/src/__tests__/unit/delta.test.ts b/src/__tests__/unit/delta.test.ts new file mode 100644 index 0000000..93ffe13 --- /dev/null +++ b/src/__tests__/unit/delta.test.ts @@ -0,0 +1,41 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { jsx } from "hono/jsx"; +import { Delta } from "../../components/delta"; + +function render(props: { pct: number; lang: string }): string { + return String(jsx(Delta, props)); +} + +describe("Delta component", () => { + it("renders +1,400% for an en delta of 1400", () => { + const out = render({ pct: 1400, lang: "en" }); + expect(out).toContain("+1,400%"); + expect(out).toContain("delta up"); + expect(out).toContain("trending_up"); + }); + + it("normalizes -0 to 0 so the flat delta does not render -0%", () => { + // Math.round(((99.9 - 100) / 100) * 100) === -0, which Intl.NumberFormat + // would otherwise render as "-0". + const out = render({ pct: -0, lang: "en" }); + expect(out).toContain(">0%<"); + expect(out).not.toContain("-0%"); + expect(out).not.toContain("−0%"); + expect(out).toContain("delta flat"); + expect(out).toContain("trending_flat"); + }); + + it("groups thousands using the active locale", () => { + expect(render({ pct: 1400, lang: "id" })).toContain("+1.400%"); + }); + + it("renders a negative delta with a minus and the down direction", () => { + const out = render({ pct: -25, lang: "en" }); + expect(out).toContain("-25%"); + expect(out).toContain("delta down"); + expect(out).toContain("trending_down"); + }); +}); diff --git a/src/components/delta.tsx b/src/components/delta.tsx index 3e7909d..5674885 100644 --- a/src/components/delta.tsx +++ b/src/components/delta.tsx @@ -2,20 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import type { FC } from "hono/jsx"; +import { fmtNumber } from "../i18n/format"; type DeltaProps = { pct: number; + lang: string; id?: string; }; -export const Delta: FC = ({ pct, id }) => { - const dir = pct > 0 ? "up" : pct < 0 ? "down" : "flat"; +export const Delta: FC = ({ pct, lang, id }) => { + // Normalize -0 to 0 so Intl.NumberFormat does not render a confusing "-0%" + // when dir is "flat". + const safePct = Object.is(pct, -0) ? 0 : pct; + const dir = safePct > 0 ? "up" : safePct < 0 ? "down" : "flat"; const icon = dir === "up" ? "trending_up" : dir === "down" ? "trending_down" : "trending_flat"; - const sign = pct > 0 ? "+" : ""; + const sign = safePct > 0 ? "+" : ""; return ( - + {icon} - {sign}{pct}% + {sign}{fmtNumber(safePct, lang)}% ); }; diff --git a/src/components/kpi-card.tsx b/src/components/kpi-card.tsx index 613178a..68b431c 100644 --- a/src/components/kpi-card.tsx +++ b/src/components/kpi-card.tsx @@ -13,6 +13,7 @@ type KpiCardProps = { valueId?: string; deltaPct?: number | null; deltaId?: string; + lang: string; hint?: string; sparkline?: number[]; span?: 1 | 2 | 3; @@ -26,6 +27,7 @@ export const KpiCard: FC = ({ valueId, deltaPct, deltaId, + lang, hint, sparkline, span = 1, @@ -38,7 +40,7 @@ export const KpiCard: FC = ({ {icon && {icon}} {label} - {deltaPct !== undefined && deltaPct !== null && } + {deltaPct !== undefined && deltaPct !== null && }
{value}
{hint &&
{hint}
} diff --git a/src/pages/bundles.tsx b/src/pages/bundles.tsx index 0dd9567..c505473 100644 --- a/src/pages/bundles.tsx +++ b/src/pages/bundles.tsx @@ -76,7 +76,7 @@ export const BundlesPage: FC = ({ bundles, t, lang, filter, range }) => { {b.archived_at && {t("bundles.archived")}} {b.delta_pct !== undefined && ( - + )} diff --git a/src/pages/dashboard.tsx b/src/pages/dashboard.tsx index bdbec70..0f49d35 100644 --- a/src/pages/dashboard.tsx +++ b/src/pages/dashboard.tsx @@ -87,6 +87,7 @@ export const DashboardPage: FC = ({ stats, t, lang, range }) => { valueId="dash-total-links" deltaPct={d.new_links_delta} deltaId="dash-links-delta" + lang={lang} sparkline={d.timeline_links} /> = ({ stats, t, lang, range }) => { valueId="dash-clicked-links" deltaPct={d.clicked_links_delta} deltaId="dash-clicked-links-delta" + lang={lang} sparkline={d.timeline_clicked_links} /> = ({ stats, t, lang, range }) => { valueId="dash-total-clicks" deltaPct={d.total_clicks_delta} deltaId="dash-clicks-delta" + lang={lang} sparkline={d.timeline} /> = ({ stats, t, lang, range }) => { valueId="dash-clicks-per-day" deltaPct={d.clicks_per_day_delta} deltaId="dash-clicks-per-day-delta" + lang={lang} sparkline={d.timeline} /> diff --git a/src/pages/links.tsx b/src/pages/links.tsx index 9208e70..0f05a1b 100644 --- a/src/pages/links.tsx +++ b/src/pages/links.tsx @@ -247,7 +247,7 @@ export const LinksPage: FC = ({ {formatDate(link.created_at, lang)} {typeof link.delta_pct === "number" && link.total_clicks > 0 && ( - + )}