From d3374dc4616e5ec1a7ccc56a82c9a8e8a71eeb37 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 20 Feb 2026 20:20:59 -0800
Subject: [PATCH] Feature: sparkyfitness service widget (#6346)
---
docs/widgets/services/sparkyfitness.md | 15 +++++
mkdocs.yml | 1 +
public/locales/en/common.json | 6 ++
src/widgets/components.js | 1 +
src/widgets/sparkyfitness/component.jsx | 35 ++++++++++
src/widgets/sparkyfitness/component.test.jsx | 67 ++++++++++++++++++++
src/widgets/sparkyfitness/widget.js | 15 +++++
src/widgets/sparkyfitness/widget.test.js | 15 +++++
src/widgets/widgets.js | 2 +
9 files changed, 157 insertions(+)
create mode 100644 docs/widgets/services/sparkyfitness.md
create mode 100644 src/widgets/sparkyfitness/component.jsx
create mode 100644 src/widgets/sparkyfitness/component.test.jsx
create mode 100644 src/widgets/sparkyfitness/widget.js
create mode 100644 src/widgets/sparkyfitness/widget.test.js
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,