diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 44e859cb8..82eca75ef 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: crowdin action - uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2 + uses: crowdin/github-action@9af557de76d70c480f88065d336f445a362f402b # v3.1.0 with: upload_translations: false download_translations: true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6857baed7..1c145794f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -99,14 +99,14 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Setup QEMU - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Setup Docker buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abf053948..bd3b69fdc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: # Run Vitest directly so `--shard` is parsed as an option - run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info diff --git a/docs/widgets/services/arcane.md b/docs/widgets/services/arcane.md index 6c59b9ee3..0e1acb9a9 100644 --- a/docs/widgets/services/arcane.md +++ b/docs/widgets/services/arcane.md @@ -6,6 +6,7 @@ description: Arcane Widget Configuration Learn more about [Arcane](https://github.com/getarcaneapp/arcane). **Allowed fields** (max 4): `running`, `stopped`, `total`, `images`, `images_used`, `images_unused`, `image_updates`. + **Default fields**: `running`, `stopped`, `total`, `image_updates`. ```yaml @@ -17,4 +18,4 @@ widget: fields: ["running", "stopped", "total", "image_updates"] # optional ``` -Using an api key for the widget requires permissions for: `containers:list` `images:list and `image-updates:read` +Using an API key for the widget requires permissions for: `containers:list`, `images:list`, and `image-updates:read`. diff --git a/next-i18next.config.js b/next-i18next.config.js index ac1eac369..5c3613b3a 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -106,6 +106,12 @@ function relativeDate(date, formatter) { const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(delta)); const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; + if (units[unitIndex] === "day") { + // compare calendar days, not elapsed 24h blocks + const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return formatter.format(Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000), "day"); + } + return formatter.format(Math.floor(delta / divisor), units[unitIndex]); } diff --git a/src/utils/relative-date-formatter.test.js b/src/utils/relative-date-formatter.test.js new file mode 100644 index 000000000..baf1b0e69 --- /dev/null +++ b/src/utils/relative-date-formatter.test.js @@ -0,0 +1,46 @@ +import { createRequire } from "node:module"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const i18nextConfig = require("../../next-i18next.config.js"); + +function getRelativeDateFormatter() { + let relativeDateFormatter; + + i18nextConfig.use[0].init({ + services: { + formatter: { + add(name, formatter) { + if (name === "relativeDate") relativeDateFormatter = formatter; + }, + }, + }, + }); + + return relativeDateFormatter; +} + +describe("relativeDate formatter", () => { + const formatRelativeDate = getRelativeDateFormatter(); + const options = { numeric: "auto" }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 23, 16, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["hours away on the same day", new Date(2026, 8, 23, 20, 0), "in 4 hours"], + ["next calendar day", new Date(2026, 8, 24, 20, 0), "tomorrow"], + ["two calendar days away but under 48h", new Date(2026, 8, 25, 10, 40), "in 2 days"], + ["previous calendar day", new Date(2026, 8, 22, 10, 0), "yesterday"], + ["two calendar days ago but under 48h", new Date(2026, 8, 21, 20, 0), "2 days ago"], + ])("formats %s", (_description, value, expected) => { + expect(formatRelativeDate(value.toISOString(), "en", options)).toBe(expected); + }); +}); diff --git a/src/widgets/gitea/widget.js b/src/widgets/gitea/widget.js index b0420ccc6..003a6710d 100644 --- a/src/widgets/gitea/widget.js +++ b/src/widgets/gitea/widget.js @@ -11,10 +11,13 @@ const widget = { }, issues: { endpoint: "repos/issues/search", - map: (data) => ({ - pulls: asJson(data).filter((issue) => issue.pull_request), - issues: asJson(data).filter((issue) => !issue.pull_request), - }), + map: (data) => { + const items = asJson(data); + return { + pulls: items.filter((issue) => issue.pull_request), + issues: items.filter((issue) => !issue.pull_request), + }; + }, }, repositories: { endpoint: "repos/search", diff --git a/src/widgets/gitea/widget.test.js b/src/widgets/gitea/widget.test.js index 75395c126..1039ad7ce 100644 --- a/src/widgets/gitea/widget.test.js +++ b/src/widgets/gitea/widget.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { expectWidgetConfigShape } from "test-utils/widget-config"; @@ -8,4 +8,13 @@ describe("gitea widget config", () => { it("exports a valid widget config", () => { expectWidgetConfigShape(widget); }); + + it("splits issues and pull requests", () => { + const result = widget.mappings.issues.map( + Buffer.from(JSON.stringify([{ id: 1, pull_request: {} }, { id: 2 }, { id: 3 }])), + ); + + expect(result.pulls.map((i) => i.id)).toEqual([1]); + expect(result.issues.map((i) => i.id)).toEqual([2, 3]); + }); }); diff --git a/src/widgets/miniflux/widget.js b/src/widgets/miniflux/widget.js index dbf4bbf37..aebe2cf97 100644 --- a/src/widgets/miniflux/widget.js +++ b/src/widgets/miniflux/widget.js @@ -8,10 +8,13 @@ const widget = { mappings: { counters: { endpoint: "feeds/counters", - map: (data) => ({ - read: Object.values(asJson(data).reads).reduce((acc, i) => acc + i, 0), - unread: Object.values(asJson(data).unreads).reduce((acc, i) => acc + i, 0), - }), + map: (data) => { + const { reads, unreads } = asJson(data); + return { + read: Object.values(reads).reduce((acc, i) => acc + i, 0), + unread: Object.values(unreads).reduce((acc, i) => acc + i, 0), + }; + }, }, }, }; diff --git a/src/widgets/miniflux/widget.test.js b/src/widgets/miniflux/widget.test.js index 62f7ad9b6..272d1fc64 100644 --- a/src/widgets/miniflux/widget.test.js +++ b/src/widgets/miniflux/widget.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { expectWidgetConfigShape } from "test-utils/widget-config"; @@ -8,4 +8,12 @@ describe("miniflux widget config", () => { it("exports a valid widget config", () => { expectWidgetConfigShape(widget); }); + + it("sums read and unread counters", () => { + const result = widget.mappings.counters.map( + Buffer.from(JSON.stringify({ reads: { 1: 2, 2: 3 }, unreads: { 1: 4, 2: 1 } })), + ); + + expect(result).toEqual({ read: 5, unread: 5 }); + }); }); diff --git a/src/widgets/radarr/widget.js b/src/widgets/radarr/widget.js index 5170325d6..1f307430d 100644 --- a/src/widgets/radarr/widget.js +++ b/src/widgets/radarr/widget.js @@ -1,4 +1,4 @@ -import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers"; +import { asJson } from "utils/proxy/api-helpers"; import genericProxyHandler from "utils/proxy/handlers/generic"; const widget = { @@ -8,15 +8,18 @@ const widget = { mappings: { movie: { endpoint: "movie", - map: (data) => ({ - wanted: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile && item.isAvailable).length, - have: jsonArrayFilter(data, (item) => item.hasFile).length, - missing: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile).length, - all: asJson(data).map((entry) => ({ - title: entry.title, - id: entry.id, - })), - }), + map: (data) => { + const movieData = asJson(data) ?? []; + return { + wanted: movieData.filter((item) => item.monitored && !item.hasFile && item.isAvailable).length, + have: movieData.filter((item) => item.hasFile).length, + missing: movieData.filter((item) => item.monitored && !item.hasFile).length, + all: movieData.map((entry) => ({ + title: entry.title, + id: entry.id, + })), + }; + }, }, "queue/status": { endpoint: "queue/status", diff --git a/src/widgets/radarr/widget.test.js b/src/widgets/radarr/widget.test.js index c7a19f39b..9e19aecfa 100644 --- a/src/widgets/radarr/widget.test.js +++ b/src/widgets/radarr/widget.test.js @@ -33,4 +33,27 @@ describe("radarr widget config", () => { expect(queue.map((entry) => entry.movieId)).toEqual([2, 1]); }); + + it("maps movie counts and titles", () => { + const movies = widget.mappings.movie.map( + Buffer.from( + JSON.stringify([ + { id: 1, title: "A", monitored: true, hasFile: false, isAvailable: true }, + { id: 2, title: "B", monitored: true, hasFile: true }, + { id: 3, title: "C", monitored: true, hasFile: false, isAvailable: false }, + ]), + ), + ); + + expect(movies).toEqual({ + wanted: 1, + have: 1, + missing: 2, + all: [ + { title: "A", id: 1 }, + { title: "B", id: 2 }, + { title: "C", id: 3 }, + ], + }); + }); }); diff --git a/src/widgets/unmanic/widget.js b/src/widgets/unmanic/widget.js index ef4493e9a..0544c0c68 100644 --- a/src/widgets/unmanic/widget.js +++ b/src/widgets/unmanic/widget.js @@ -8,10 +8,13 @@ const widget = { mappings: { workers: { endpoint: "workers/status", - map: (data) => ({ - total_workers: asJson(data).workers_status.length, - active_workers: asJson(data).workers_status.filter((worker) => !worker.idle).length, - }), + map: (data) => { + const workers = asJson(data).workers_status; + return { + total_workers: workers.length, + active_workers: workers.filter((worker) => !worker.idle).length, + }; + }, }, pending: { method: "POST", diff --git a/src/widgets/unmanic/widget.test.js b/src/widgets/unmanic/widget.test.js index 2997a66b6..f153a6032 100644 --- a/src/widgets/unmanic/widget.test.js +++ b/src/widgets/unmanic/widget.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { expectWidgetConfigShape } from "test-utils/widget-config"; @@ -8,4 +8,12 @@ describe("unmanic widget config", () => { it("exports a valid widget config", () => { expectWidgetConfigShape(widget); }); + + it("counts total and active workers", () => { + const result = widget.mappings.workers.map( + Buffer.from(JSON.stringify({ workers_status: [{ idle: true }, { idle: false }, { idle: false }] })), + ); + + expect(result).toEqual({ total_workers: 3, active_workers: 2 }); + }); });