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
19 changes: 15 additions & 4 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions sdk/dart/LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions sdk/python/LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions sdk/typescript/LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions src/__tests__/page/links-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -133,6 +133,27 @@ describe("Links listing page", () => {
expect(html).toMatch(/<td[^>]*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%</);
expect(html).not.toMatch(/class="delta-label">\+1400%</);
});

it("pagination shows a '1–N of Total' summary", async () => {
for (let i = 0; i < 30; i++) {
await LinkRepository.create(env.DB, {
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/unit/delta.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
15 changes: 10 additions & 5 deletions src/components/delta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeltaProps> = ({ pct, id }) => {
const dir = pct > 0 ? "up" : pct < 0 ? "down" : "flat";
export const Delta: FC<DeltaProps> = ({ pct, lang, id }) => {
Comment on lines 7 to +13
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping lang required here. There are only three Delta callers (dashboard.tsx via KpiCard, bundles.tsx, links.tsx) and lang is already in scope at each — the prop drilling is one line per call site. A silent fallback would mask future i18n bugs (the server has no meaningful "user locale" outside the cookie/settings, so Intl.NumberFormat().resolvedOptions().locale would just return the Worker's runtime locale, not the user's). The matching server-side fmtNumber(n, lang) helper takes lang as required for the same reason.


Generated by Claude Code

// 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 ? "+" : "";
Comment on lines 7 to +19
return (
<span class={`delta ${dir}`} id={id} data-delta={String(pct)}>
<span class={`delta ${dir}`} id={id} data-delta={String(safePct)}>
<span class="icon">{icon}</span>
<span class="delta-label">{sign}{pct}%</span>
<span class="delta-label">{sign}{fmtNumber(safePct, lang)}%</span>
</span>
);
};
4 changes: 3 additions & 1 deletion src/components/kpi-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type KpiCardProps = {
valueId?: string;
deltaPct?: number | null;
deltaId?: string;
lang: string;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer as the Delta thread above: KpiCard is only used inside admin pages where lang is already in scope, and a silent fallback would mask future i18n bugs. Keeping lang required so the type system catches a missing locale at the call site rather than rendering en-US-grouped numbers to a Swedish or Indonesian user.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settled in the prior kpi-card.tsx:16 thread above — keeping lang required by design so the type system catches a missing locale at the call site (admin-only callers, no meaningful Worker runtime locale to fall back to).

hint?: string;
sparkline?: number[];
span?: 1 | 2 | 3;
Expand All @@ -26,6 +27,7 @@ export const KpiCard: FC<KpiCardProps> = ({
valueId,
deltaPct,
deltaId,
lang,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — keeping lang required, see the line 16 thread.


Generated by Claude Code

hint,
sparkline,
span = 1,
Expand All @@ -38,7 +40,7 @@ export const KpiCard: FC<KpiCardProps> = ({
{icon && <span class="icon">{icon}</span>}
<span>{label}</span>
</div>
{deltaPct !== undefined && deltaPct !== null && <Delta pct={deltaPct} id={deltaId} />}
{deltaPct !== undefined && deltaPct !== null && <Delta pct={deltaPct} lang={lang} id={deltaId} />}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — keeping lang required, see the line 16 thread.


Generated by Claude Code

</div>
<div class="kpi-value" id={valueId}>{value}</div>
{hint && <div class="kpi-hint">{hint}</div>}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/bundles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const BundlesPage: FC<Props> = ({ bundles, t, lang, filter, range }) => {
{b.archived_at && <span class="bundle-archived-badge">{t("bundles.archived")}</span>}
{b.delta_pct !== undefined && (
<span class="bundle-card-delta">
<Delta pct={b.delta_pct} />
<Delta pct={b.delta_pct} lang={lang} />
</span>
)}
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/pages/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const DashboardPage: FC<Props> = ({ stats, t, lang, range }) => {
valueId="dash-total-links"
deltaPct={d.new_links_delta}
deltaId="dash-links-delta"
lang={lang}
sparkline={d.timeline_links}
/>
<KpiCard
Expand All @@ -97,6 +98,7 @@ export const DashboardPage: FC<Props> = ({ 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}
/>
<KpiCard
Expand All @@ -107,6 +109,7 @@ export const DashboardPage: FC<Props> = ({ stats, t, lang, range }) => {
valueId="dash-total-clicks"
deltaPct={d.total_clicks_delta}
deltaId="dash-clicks-delta"
lang={lang}
sparkline={d.timeline}
/>
<KpiCard
Expand All @@ -117,6 +120,7 @@ export const DashboardPage: FC<Props> = ({ 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}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export const LinksPage: FC<Props> = ({
<span class="col-date-cell">
<span>{formatDate(link.created_at, lang)}</span>
{typeof link.delta_pct === "number" && link.total_clicks > 0 && (
<Delta pct={link.delta_pct} />
<Delta pct={link.delta_pct} lang={lang} />
)}
</span>
</td>
Expand Down