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:
@@ -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,
|
||||
|
||||
23
packages/api/src/router/widgets/stocks.ts
Normal file
23
packages/api/src/router/widgets/stocks.ts
Normal 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 });
|
||||
}),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const widgetKinds = [
|
||||
"dnsHoleControls",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
"stockPrice",
|
||||
"mediaServer",
|
||||
"calendar",
|
||||
"downloads",
|
||||
|
||||
58
packages/request-handler/src/stock-price.ts
Normal file
58
packages/request-handler/src/stock-price.ts
Normal 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(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
102
packages/widgets/src/stocks/component.tsx
Normal file
102
packages/widgets/src/stocks/component.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
packages/widgets/src/stocks/index.ts
Normal file
37
packages/widgets/src/stocks/index.ts
Normal 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"));
|
||||
Reference in New Issue
Block a user