Feature: sparkyfitness service widget (#6346)

This commit is contained in:
shamoon
2026-02-20 20:20:59 -08:00
committed by GitHub
parent 795e2505ca
commit d3374dc461
9 changed files with 157 additions and 0 deletions

View File

@@ -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
```

View File

@@ -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

View File

@@ -1174,5 +1174,11 @@
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
},
"sparkyfitness": {
"eaten": "Eaten",
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
}
}

View File

@@ -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")),

View File

@@ -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 <Container service={service} error={error} />;
}
if (!data) {
return (
<Container service={service}>
<Block label="sparkyfitness.eaten" />
<Block label="sparkyfitness.burned" />
<Block label="sparkyfitness.remaining" />
<Block label="sparkyfitness.steps" />
</Container>
);
}
return (
<Container service={service}>
<Block label={t("sparkyfitness.eaten", "Eaten")} value={t("common.number", { value: data.eaten })} />
<Block label={t("sparkyfitness.burned", "Burned")} value={t("common.number", { value: data.burned })} />
<Block label={t("sparkyfitness.remaining", "Remaining")} value={t("common.number", { value: data.remaining })} />
<Block label={t("sparkyfitness.steps", "Steps")} value={t("common.number", { value: data.steps })} />
</Container>
);
}

View File

@@ -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(<Component service={service} />, { 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(<Component service={{ widget: { type: "sparkyfitness", url: "http://x" } }} />, {
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(
<Component service={{ widget: { type: "sparkyfitness", url: "http://x" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "sparkyfitness.eaten", 100);
expectBlockValue(container, "sparkyfitness.burned", 200);
expectBlockValue(container, "sparkyfitness.remaining", 300);
expectBlockValue(container, "sparkyfitness.steps", 400);
});
});

View File

@@ -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;

View File

@@ -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"]);
});
});

View File

@@ -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,