feat: stock price widget (#2579)

* feat: added stock price widget

* fix: formatting

* fix: broken lock file

* fix: requested changes

* fix: added parsing schema

* fix: improve time range and interval inputs

* fix: only return required data

* fix: formatting

* fix: deepsource tests

* fix: moved all time frames into one location

* fix: formatting

* fix: requested changes

* fix: formatting

* fix: parse response data

* fix: update packages

* fix: typescript issues

* fix: formatting

* fix: broken lockfile

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Jack Weller
2025-03-21 02:49:19 +10:00
committed by GitHub
parent e3fcfbe916
commit 91a69c162a
10 changed files with 547 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import { notebookRouter } from "./notebook";
import { optionsRouter } from "./options";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
import { stockPriceRouter } from "./stocks";
import { weatherRouter } from "./weather";
export const widgetRouter = createTRPCRouter({
@@ -21,6 +22,7 @@ export const widgetRouter = createTRPCRouter({
app: appRouter,
dnsHole: dnsHoleRouter,
smartHome: smartHomeRouter,
stockPrice: stockPriceRouter,
mediaServer: mediaServerRouter,
calendar: calendarRouter,
downloads: downloadsRouter,

View File

@@ -0,0 +1,23 @@
import { z } from "zod";
import { fetchStockPriceHandler } from "@homarr/request-handler/stock-price";
import { stockPriceTimeFrames } from "../../../../widgets/src/stocks";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const stockPriceInputSchema = z.object({
stock: z.string().nonempty(),
timeRange: z.enum(stockPriceTimeFrames.range),
timeInterval: z.enum(stockPriceTimeFrames.interval),
});
export const stockPriceRouter = createTRPCRouter({
getPriceHistory: publicProcedure.input(stockPriceInputSchema).query(async ({ input }) => {
const innerHandler = fetchStockPriceHandler.handler({
stock: input.stock,
timeRange: input.timeRange,
timeInterval: input.timeInterval,
});
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
}),
});

View File

@@ -9,6 +9,7 @@ export const widgetKinds = [
"dnsHoleControls",
"smartHome-entityState",
"smartHome-executeAutomation",
"stockPrice",
"mediaServer",
"calendar",
"downloads",

View File

@@ -0,0 +1,58 @@
import dayjs from "dayjs";
import { z } from "zod";
import { fetchWithTimeout } from "@homarr/common";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
export const fetchStockPriceHandler = createCachedWidgetRequestHandler({
queryKey: "fetchStockPriceResult",
widgetKind: "stockPrice",
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
const response = await fetchWithTimeout(
`https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`,
);
const data = dataSchema.parse(await response.json());
if ("error" in data) {
throw new Error(data.error.description);
}
if (data.chart.result.length !== 1) {
throw new Error("Received multiple results");
}
if (!data.chart.result[0]) {
throw new Error("Received invalid data");
}
return data.chart.result[0];
},
cacheDuration: dayjs.duration(5, "minutes"),
});
const dataSchema = z
.object({
error: z.object({
description: z.string(),
}),
})
.or(
z.object({
chart: z.object({
result: z.array(
z.object({
indicators: z.object({
quote: z.array(
z.object({
close: z.array(z.number()),
}),
),
}),
meta: z.object({
symbol: z.string(),
shortName: z.string(),
}),
}),
),
}),
}),
);

View File

@@ -1424,6 +1424,82 @@
"run": "Run {name}"
}
},
"stockPrice": {
"name": "Stock Price",
"description": "Displays the current stock price of a company",
"option": {
"stock": {
"label": "Stock symbol"
},
"timeRange": {
"label": "Time Range",
"option": {
"1d": {
"label": "1 Day"
},
"5d": {
"label": "5 Day"
},
"1mo": {
"label": "1 Month"
},
"3mo": {
"label": "3 Months"
},
"6mo": {
"label": "6 Months"
},
"ytd": {
"label": "Year to Date"
},
"1y": {
"label": "1 Year"
},
"2y": {
"label": "2 Years"
},
"5y": {
"label": "5 Years"
},
"10y": {
"label": "10 Years"
},
"max": {
"label": "Max"
}
}
},
"timeInterval": {
"label": "Time Interval",
"option": {
"5m": {
"label": "5 Minutes"
},
"15m": {
"label": "15 Minutes"
},
"30m": {
"label": "30 Minutes"
},
"1h": {
"label": "1 Hour"
},
"1d": {
"label": "1 Day"
},
"5d": {
"label": "5 Days"
},
"1wk": {
"label": "1 Week"
},
"1mo": {
"label": "1 Month"
}
}
}
}
},
"calendar": {
"name": "Calendar",
"description": "Display events from your integrations in a calendar view within a certain relative time period",

View File

@@ -44,6 +44,7 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/charts": "^7.17.2",
"@mantine/core": "^7.17.2",
"@mantine/hooks": "^7.17.2",
"@tabler/icons-react": "^3.31.0",
@@ -68,6 +69,7 @@
"next": "15.1.7",
"react": "19.0.0",
"react-dom": "19.0.0",
"recharts": "^2.15.1",
"video.js": "^8.22.0",
"zod": "^3.24.2"
},

View File

@@ -29,6 +29,7 @@ import type { WidgetOptionDefinition } from "./options";
import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as stockPrice from "./stocks";
import * as video from "./video";
import * as weather from "./weather";
@@ -46,6 +47,7 @@ export const widgetImports = {
dnsHoleControls,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
stockPrice,
mediaServer,
calendar,
downloads,

View File

@@ -0,0 +1,102 @@
"use client";
import { Sparkline } from "@mantine/charts";
import { Flex, Stack, Text, Title, useMantineTheme } from "@mantine/core";
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
function round(value: number) {
return Math.round(value * 100) / 100;
}
function calculateChange(valueA: number, valueB: number) {
return valueA - valueB;
}
function calculateChangePercentage(valueA: number, valueB: number) {
return 100 * ((valueA - valueB) / valueA);
}
export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
const t = useScopedI18n("widget.stockPrice");
const theme = useMantineTheme();
const [{ data }] = clientApi.widget.stockPrice.getPriceHistory.useSuspenseQuery(options);
const stockValues = data.indicators.quote[0]?.close ?? [];
const stockValuesChange = round(calculateChange(stockValues[stockValues.length - 1] ?? 0, stockValues[0] ?? 0));
const stockValuesChangePercentage = round(
calculateChangePercentage(stockValues[stockValues.length - 1] ?? 0, stockValues[0] ?? 0),
);
const stockValuesMin = Math.min(...stockValues);
const stockGraphValues = stockValues.map((value) => value - stockValuesMin + 50);
return (
<Flex h="100%" w="100%">
<Sparkline
pos="absolute"
bottom={10}
w="100%"
h={height > 280 ? "75%" : "50%"}
data={stockGraphValues}
curveType="linear"
trendColors={{ positive: "green.7", negative: "red.7", neutral: "gray.6" }}
fillOpacity={0.6}
strokeWidth={2.5}
/>
<Stack pos="absolute" top={10} left={10}>
<Text size="xl" fw={700} lh="0.715">
{stockValuesChange > 0 ? (
<IconTrendingUp size="1.5rem" color={theme.colors.green[7]} />
) : (
<IconTrendingDown size="1.5rem" color={theme.colors.red[7]} />
)}
{data.meta.symbol}
</Text>
{width > 280 && height > 280 && (
<Text size="md" lh="1">
{data.meta.shortName}
</Text>
)}
</Stack>
<Title pos="absolute" bottom={10} right={10} order={width > 280 ? 1 : 2} fw={700}>
{round(stockValues[stockValues.length - 1] ?? 0)}
</Title>
{width > 280 && (
<Text pos="absolute" top={10} right={10} size="xl" fw={700}>
{Math.abs(stockValuesChange)} ({Math.abs(stockValuesChangePercentage)}%)
</Text>
)}
{width > 280 && (
<Text pos="absolute" bottom={10} left={10} fw={700}>
{t(`option.timeRange.option.${options.timeRange}.label`)}
</Text>
)}
<Stack pos="absolute" top={10} left={10}>
<Text size="xl" fw={700} lh="0.715">
{stockValuesChange > 0 ? (
<IconTrendingUp size="1.5rem" color={theme.colors.green[7]} />
) : (
<IconTrendingDown size="1.5rem" color={theme.colors.red[7]} />
)}
{data.meta.symbol}
</Text>
{width > 280 && height > 280 && (
<Text size="md" lh="1">
{data.meta.shortName}
</Text>
)}
</Stack>
</Flex>
);
}

View File

@@ -0,0 +1,37 @@
import { IconBuildingBank } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const stockPriceTimeFrames = {
range: ["1d", "5d", "1mo", "3mo", "6mo", "ytd", "1y", "2y", "5y", "10y", "max"] as const,
interval: ["5m", "15m", "30m", "1h", "1d", "5d", "1wk", "1mo"] as const,
};
const timeRangeOptions = stockPriceTimeFrames.range;
const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank,
createOptions() {
return optionsBuilder.from((factory) => ({
stock: factory.text({
defaultValue: "AAPL",
}),
timeRange: factory.select({
defaultValue: "1mo",
options: timeRangeOptions.map((value) => ({
value,
label: (t) => t(`widget.stockPrice.option.timeRange.option.${value}.label`),
})),
}),
timeInterval: factory.select({
defaultValue: "1d",
options: timeIntervalOptions.map((value) => ({
value,
label: (t) => t(`widget.stockPrice.option.timeInterval.option.${value}.label`),
})),
}),
}));
},
}).withDynamicImport(() => import("./component"));