diff --git a/docs/widgets/services/sparkyfitness.md b/docs/widgets/services/sparkyfitness.md new file mode 100644 index 000000000..879be8870 --- /dev/null +++ b/docs/widgets/services/sparkyfitness.md @@ -0,0 +1,15 @@ +--- +title: SparkyFitness +description: SparkyFitness Widget Configuration +--- + +Learn more about [SparkyFitness](https://github.com/CodeWithCJ/SparkyFitness). + +Allowed fields: `["eaten", "burned", "remaining", "steps"]`. + +```yaml +widget: + type: sparkyfitness + url: http://sparkyfitness.host.or.ip + key: apikeyapikeyapikeyapikeyapikey +``` diff --git a/mkdocs.yml b/mkdocs.yml index 9004552e8..27a58a53c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -152,6 +152,7 @@ nav: - widgets/services/seerr.md - widgets/services/slskd.md - widgets/services/sonarr.md + - widgets/services/sparkyfitness.md - widgets/services/speedtest-tracker.md - widgets/services/spoolman.md - widgets/services/stash.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 76245f286..66a6a34a7 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1174,5 +1174,11 @@ "paused": "Paused", "total": "Total", "environment_not_found": "Environment Not Found" + }, + "sparkyfitness": { + "eaten": "Eaten", + "burned": "Burned", + "remaining": "Remaining", + "steps": "Steps" } } diff --git a/src/widgets/components.js b/src/widgets/components.js index 64043ad53..472ddd684 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -127,6 +127,7 @@ const components = { seerr: dynamic(() => import("./seerr/component")), slskd: dynamic(() => import("./slskd/component")), sonarr: dynamic(() => import("./sonarr/component")), + sparkyfitness: dynamic(() => import("./sparkyfitness/component")), speedtest: dynamic(() => import("./speedtest/component")), spoolman: dynamic(() => import("./spoolman/component")), stash: dynamic(() => import("./stash/component")), diff --git a/src/widgets/sparkyfitness/component.jsx b/src/widgets/sparkyfitness/component.jsx new file mode 100644 index 000000000..c9c1db42b --- /dev/null +++ b/src/widgets/sparkyfitness/component.jsx @@ -0,0 +1,35 @@ +import Block from "components/services/widget/block"; +import Container from "components/services/widget/container"; +import { useTranslation } from "next-i18next"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + const { data, error } = useWidgetAPI(widget, "stats"); + + if (error) { + return ; + } + + if (!data) { + return ( + + + + + + + ); + } + + return ( + + + + + + + ); +} diff --git a/src/widgets/sparkyfitness/component.test.jsx b/src/widgets/sparkyfitness/component.test.jsx new file mode 100644 index 000000000..f498e44aa --- /dev/null +++ b/src/widgets/sparkyfitness/component.test.jsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "test-utils/render-with-providers"; +import { findServiceBlockByLabel } from "test-utils/widget-assertions"; + +const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); +vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); + +import Component from "./component"; + +function expectBlockValue(container, label, value) { + const block = findServiceBlockByLabel(container, label); + expect(block, `missing block for ${label}`).toBeTruthy(); + expect(block.textContent).toContain(String(value)); +} + +describe("widgets/sparkyfitness/component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls the stats endpoint and renders placeholders while loading", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + + const service = { widget: { type: "sparkyfitness", url: "http://x" } }; + const { container } = renderWithProviders(, { settings: { hideErrors: false } }); + + expect(useWidgetAPI).toHaveBeenCalledWith(service.widget, "stats"); + expect(container.querySelectorAll(".service-block")).toHaveLength(4); + expect(screen.getByText("sparkyfitness.eaten")).toBeInTheDocument(); + expect(screen.getByText("sparkyfitness.burned")).toBeInTheDocument(); + expect(screen.getByText("sparkyfitness.remaining")).toBeInTheDocument(); + expect(screen.getByText("sparkyfitness.steps")).toBeInTheDocument(); + expect(screen.getAllByText("-")).toHaveLength(4); + }); + + it("renders error UI when widget API errors", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0); + expect(screen.getByText("nope")).toBeInTheDocument(); + }); + + it("renders numeric values when loaded", () => { + useWidgetAPI.mockReturnValue({ + data: { eaten: 100, burned: 200, remaining: 300, steps: 400 }, + error: undefined, + }); + + const { container } = renderWithProviders( + , + { settings: { hideErrors: false } }, + ); + + expectBlockValue(container, "sparkyfitness.eaten", 100); + expectBlockValue(container, "sparkyfitness.burned", 200); + expectBlockValue(container, "sparkyfitness.remaining", 300); + expectBlockValue(container, "sparkyfitness.steps", 400); + }); +}); diff --git a/src/widgets/sparkyfitness/widget.js b/src/widgets/sparkyfitness/widget.js new file mode 100644 index 000000000..4447fc943 --- /dev/null +++ b/src/widgets/sparkyfitness/widget.js @@ -0,0 +1,15 @@ +import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; + +const widget = { + api: "{url}/{endpoint}", + proxyHandler: credentialedProxyHandler, + + mappings: { + stats: { + endpoint: "api/dashboard/stats", + validate: ["eaten", "burned", "remaining", "steps"], + }, + }, +}; + +export default widget; diff --git a/src/widgets/sparkyfitness/widget.test.js b/src/widgets/sparkyfitness/widget.test.js new file mode 100644 index 000000000..50889a3a1 --- /dev/null +++ b/src/widgets/sparkyfitness/widget.test.js @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { expectWidgetConfigShape } from "test-utils/widget-config"; + +import widget from "./widget"; + +describe("sparkyfitness widget config", () => { + it("exports a valid widget config", () => { + expectWidgetConfigShape(widget); + + const statsMapping = widget.mappings?.stats; + expect(statsMapping?.endpoint).toBe("api/dashboard/stats"); + expect(statsMapping?.validate).toEqual(["eaten", "burned", "remaining", "steps"]); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 52f26d103..533410bdc 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -117,6 +117,7 @@ import scrutiny from "./scrutiny/widget"; import seerr from "./seerr/widget"; import slskd from "./slskd/widget"; import sonarr from "./sonarr/widget"; +import sparkyfitness from "./sparkyfitness/widget"; import speedtest from "./speedtest/widget"; import spoolman from "./spoolman/widget"; import stash from "./stash/widget"; @@ -274,6 +275,7 @@ const widgets = { seerr, slskd, sonarr, + sparkyfitness, speedtest, spoolman, stash,