mirror of
https://github.com/gethomepage/homepage.git
synced 2026-05-18 19:40:58 +08:00
Feature: sparkyfitness service widget (#6346)
This commit is contained in:
15
docs/widgets/services/sparkyfitness.md
Normal file
15
docs/widgets/services/sparkyfitness.md
Normal 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
|
||||||
|
```
|
||||||
@@ -152,6 +152,7 @@ nav:
|
|||||||
- widgets/services/seerr.md
|
- widgets/services/seerr.md
|
||||||
- widgets/services/slskd.md
|
- widgets/services/slskd.md
|
||||||
- widgets/services/sonarr.md
|
- widgets/services/sonarr.md
|
||||||
|
- widgets/services/sparkyfitness.md
|
||||||
- widgets/services/speedtest-tracker.md
|
- widgets/services/speedtest-tracker.md
|
||||||
- widgets/services/spoolman.md
|
- widgets/services/spoolman.md
|
||||||
- widgets/services/stash.md
|
- widgets/services/stash.md
|
||||||
|
|||||||
@@ -1174,5 +1174,11 @@
|
|||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"environment_not_found": "Environment Not Found"
|
"environment_not_found": "Environment Not Found"
|
||||||
|
},
|
||||||
|
"sparkyfitness": {
|
||||||
|
"eaten": "Eaten",
|
||||||
|
"burned": "Burned",
|
||||||
|
"remaining": "Remaining",
|
||||||
|
"steps": "Steps"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ const components = {
|
|||||||
seerr: dynamic(() => import("./seerr/component")),
|
seerr: dynamic(() => import("./seerr/component")),
|
||||||
slskd: dynamic(() => import("./slskd/component")),
|
slskd: dynamic(() => import("./slskd/component")),
|
||||||
sonarr: dynamic(() => import("./sonarr/component")),
|
sonarr: dynamic(() => import("./sonarr/component")),
|
||||||
|
sparkyfitness: dynamic(() => import("./sparkyfitness/component")),
|
||||||
speedtest: dynamic(() => import("./speedtest/component")),
|
speedtest: dynamic(() => import("./speedtest/component")),
|
||||||
spoolman: dynamic(() => import("./spoolman/component")),
|
spoolman: dynamic(() => import("./spoolman/component")),
|
||||||
stash: dynamic(() => import("./stash/component")),
|
stash: dynamic(() => import("./stash/component")),
|
||||||
|
|||||||
35
src/widgets/sparkyfitness/component.jsx
Normal file
35
src/widgets/sparkyfitness/component.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/widgets/sparkyfitness/component.test.jsx
Normal file
67
src/widgets/sparkyfitness/component.test.jsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/widgets/sparkyfitness/widget.js
Normal file
15
src/widgets/sparkyfitness/widget.js
Normal 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;
|
||||||
15
src/widgets/sparkyfitness/widget.test.js
Normal file
15
src/widgets/sparkyfitness/widget.test.js
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,6 +117,7 @@ import scrutiny from "./scrutiny/widget";
|
|||||||
import seerr from "./seerr/widget";
|
import seerr from "./seerr/widget";
|
||||||
import slskd from "./slskd/widget";
|
import slskd from "./slskd/widget";
|
||||||
import sonarr from "./sonarr/widget";
|
import sonarr from "./sonarr/widget";
|
||||||
|
import sparkyfitness from "./sparkyfitness/widget";
|
||||||
import speedtest from "./speedtest/widget";
|
import speedtest from "./speedtest/widget";
|
||||||
import spoolman from "./spoolman/widget";
|
import spoolman from "./spoolman/widget";
|
||||||
import stash from "./stash/widget";
|
import stash from "./stash/widget";
|
||||||
@@ -274,6 +275,7 @@ const widgets = {
|
|||||||
seerr,
|
seerr,
|
||||||
slskd,
|
slskd,
|
||||||
sonarr,
|
sonarr,
|
||||||
|
sparkyfitness,
|
||||||
speedtest,
|
speedtest,
|
||||||
spoolman,
|
spoolman,
|
||||||
stash,
|
stash,
|
||||||
|
|||||||
Reference in New Issue
Block a user