diff --git a/packages/api/src/router/widgets/dawarich.ts b/packages/api/src/router/widgets/dawarich.ts new file mode 100644 index 000000000..9ad21b15e --- /dev/null +++ b/packages/api/src/router/widgets/dawarich.ts @@ -0,0 +1,29 @@ +import { z } from "zod/v4"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const dawarichRouter = createTRPCRouter({ + getStatistics: publicProcedure + .concat(createOneIntegrationMiddleware("query", "dawarich")) + .query(async ({ ctx }) => { + const { dawarichStatisticsRequestHandler } = await import("@homarr/request-handler/dawarich"); + const innerHandler = dawarichStatisticsRequestHandler.handler(ctx.integration, {}); + const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.data; + }), + + getPlaces: publicProcedure + .concat(createOneIntegrationMiddleware("query", "dawarich")) + .input( + z.object({ + limit: z.number().min(1).max(50).default(10), + }), + ) + .query(async ({ ctx, input }) => { + const { dawarichPlacesRequestHandler } = await import("@homarr/request-handler/dawarich"); + const innerHandler = dawarichPlacesRequestHandler.handler(ctx.integration, {}); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.slice(0, input.limit); + }), +}); \ No newline at end of file diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 80776db45..e579f7943 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({ vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), + dawarich: lazy(() => import("./dawarich").then((mod) => mod.dawarichRouter)), }); diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index efc75332c..9397f6fc2 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -343,6 +343,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/gluetun" | "/docs/integrations/peanut" | "/docs/integrations/beszel" + | "/docs/integrations/dawarich" | "/docs/widgets/clock" | "/docs/widgets/weather" | "/docs/widgets/app" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 90c3cae8b..351c5b435 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -59,5 +59,6 @@ export const integrationDocSlugs: Record = { peaNut: "peanut", beszel: "beszel", archiveTeamWarrior: null, + dawarich: "dawarich", mock: null, }; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 2e34a4843..f2a5d076f 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -51,4 +51,6 @@ export const widgetDocSlugs: Record = { beszelAlerts: "beszel-alerts", beszelSystemStats: "beszel-system-stats", customApi: "custom-api", + dawarichStatistics: null, + dawarichPlaces: null, }; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index a560f2979..19f3bcd32 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -40,6 +40,8 @@ export const widgetIntegrationSupport: Partial { diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index db2028082..a3f3b0a34 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -49,6 +49,8 @@ export const widgetKinds = [ "beszelAlerts", "beszelSystemStats", "customApi", + "dawarichStatistics", + "dawarichPlaces", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/dawarich/dawarich-schemas.ts b/packages/integrations/src/dawarich/dawarich-schemas.ts index 2ee43507c..f4b8eedf2 100644 --- a/packages/integrations/src/dawarich/dawarich-schemas.ts +++ b/packages/integrations/src/dawarich/dawarich-schemas.ts @@ -1,15 +1,21 @@ -import { z } from "zod/v4"; +import {z} from "zod/v4"; export const dawarichStatisticsSchema = z.object({ - totalDistanceKm: z.number(), - totalPointsTracked: z.number(), - totalReverseGeocodedPoints: z.number(), - totalCountriesVisited: z.number(), - totalCitiesVisited: z.number(), + totalDistanceKm: z.number(), + totalPointsTracked: z.number(), + totalReverseGeocodedPoints: z.number(), + totalCountriesVisited: z.number(), + totalCitiesVisited: z.number() }); -export const dawarichPlacesSchema = z.array( - z.object({ +export const dawarichPlaceTagSchema = z.object({ + id: z.number(), + name: z.string(), + icon: z.string(), + color: z.string() +}); + +export const dawarichPlaceSchema = z.object({ id: z.number(), name: z.string(), latitude: z.number(), @@ -18,14 +24,8 @@ export const dawarichPlacesSchema = z.array( icon: z.string().nullable(), color: z.string().nullable(), visits_count: z.number(), - created_at: "2024-07-29T15:51:28.071Z", - tags: [ - { - id: z.number(), - name: z.string(), - icon: z.string(), - color: z.string(), - }, - ], - }), -); + created_at: z.string(), + tags: z.array(dawarichPlaceTagSchema) +}); + +export const dawarichPlacesSchema = z.array(dawarichPlaceSchema); \ No newline at end of file diff --git a/packages/integrations/src/dawarich/dawarich-types.ts b/packages/integrations/src/dawarich/dawarich-types.ts new file mode 100644 index 000000000..d064ee33f --- /dev/null +++ b/packages/integrations/src/dawarich/dawarich-types.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; +import { dawarichPlacesSchema, dawarichStatisticsSchema } from "./dawarich-schemas"; + +export type DawarichStatistics = z.infer; +export type DawarichPlace = z.infer[number]; \ No newline at end of file diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 66cf4be51..ef11d9c3c 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -41,6 +41,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration"; export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration"; export { UmamiIntegration } from "./umami/umami-integration"; export { PeaNutIntegration } from "./peanut/peanut-integration"; +export { DawarichIntegration } from "./dawarich/dawarich-integration"; // Types export type { IntegrationInput } from "./base/integration"; @@ -86,6 +87,7 @@ export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshel export type { NavidromeDashboardData } from "./navidrome/navidrome-types"; export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types"; export type { UmamiVisitorStats } from "./umami/umami-types"; +export type { DawarichStatistics, DawarichPlace } from "./dawarich/dawarich-types"; // Schemas export { anchorNotesListInputSchema } from "./anchor/anchor-types"; diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index afeaf1db2..cf71ec46e 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -25,3 +25,4 @@ export * from "./paperless-ngx/paperless-ngx-types"; export * from "./umami/umami-types"; export * from "./gluetun/gluetun-types"; export * from "./beszel/beszel-types"; +export * from "./dawarich/dawarich-types"; diff --git a/packages/request-handler/src/dawarich.ts b/packages/request-handler/src/dawarich.ts new file mode 100644 index 000000000..8be9c9670 --- /dev/null +++ b/packages/request-handler/src/dawarich.ts @@ -0,0 +1,32 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { DawarichPlace, DawarichStatistics } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const dawarichStatisticsRequestHandler = createCachedIntegrationRequestHandler< + DawarichStatistics, + "dawarich", + Record +>({ + async requestAsync(integration) { + const instance = await createIntegrationAsync(integration); + return await instance.getStatisticsAsync(); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "dawarichStatistics", +}); + +export const dawarichPlacesRequestHandler = createCachedIntegrationRequestHandler< + DawarichPlace[], + "dawarich", + Record +>({ + async requestAsync(integration) { + const instance = await createIntegrationAsync(integration); + return await instance.getPlacesAsync(); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "dawarichPlaces", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index cbbd87012..89d79d30a 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3545,6 +3545,42 @@ "executeSuccess": "Action executed successfully", "executeFailed": "Action failed. Check the widget configuration." }, + "dawarichStatistics": { + "name": "Dawarich Statistics", + "description": "Display travel statistics from Dawarich", + "option": { + "showTotalDistance": { + "label": "Show total distance" + }, + "showTotalPoints": { + "label": "Show total points tracked" + }, + "showReverseGeocoded": { + "label": "Show reverse geocoded points" + }, + "showCountries": { + "label": "Show countries visited" + }, + "showCities": { + "label": "Show cities visited" + } + }, + "totalDistance": "Total distance", + "totalPoints": "Points tracked", + "reverseGeocoded": "Reverse geocoded", + "countries": "Countries", + "cities": "Cities" + }, + "dawarichPlaces": { + "name": "Dawarich Places", + "description": "Display recently visited places from Dawarich", + "option": { + "maxPlaces": { + "label": "Max places to show" + } + }, + "visits": "visits" + }, "beszelSystemTable": { "name": "Beszel Systems (Table)", "description": "Table view of all Beszel-monitored systems with sortable columns and status indicators", diff --git a/packages/widgets/src/dawarich-places/component.module.css b/packages/widgets/src/dawarich-places/component.module.css new file mode 100644 index 000000000..6aa9d7e2e --- /dev/null +++ b/packages/widgets/src/dawarich-places/component.module.css @@ -0,0 +1,10 @@ +.placeItem { + padding: 8px 12px; + border-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + transition: all 0.2s ease; + + &:hover { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } +} diff --git a/packages/widgets/src/dawarich-places/component.tsx b/packages/widgets/src/dawarich-places/component.tsx new file mode 100644 index 000000000..684b1f4a0 --- /dev/null +++ b/packages/widgets/src/dawarich-places/component.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Anchor, Group, Stack, Text } from "@mantine/core"; +import { IconMapPin } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.css"; + +export default function DawarichPlacesWidget({ + integrationIds, + options, +}: WidgetComponentProps<"dawarichPlaces">) { + const t = useI18n(); + const { data: places } = clientApi.widget.dawarich.getPlaces.useQuery( + { + integrationId: integrationIds[0] ?? "", + limit: options.maxPlaces, + }, + { staleTime: 5 * 60 * 1000 }, + ); + + if (!places || places.length === 0) return ; + + return ( + + {places.map((place) => ( + + + + {place.name} + + + {place.visits_count} {t("widget.dawarichPlaces.visits")} + + + ))} + + ); +} diff --git a/packages/widgets/src/dawarich-places/index.ts b/packages/widgets/src/dawarich-places/index.ts new file mode 100644 index 000000000..b70089fca --- /dev/null +++ b/packages/widgets/src/dawarich-places/index.ts @@ -0,0 +1,16 @@ +import { IconMapPin } from "@tabler/icons-react"; +import z from "zod"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("dawarichPlaces", { + icon: IconMapPin, + supportedIntegrations: ["dawarich"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + maxPlaces: factory.number({ defaultValue: 10, validate: z.number().min(1).max(50) }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/dawarich-statistics/component.module.css b/packages/widgets/src/dawarich-statistics/component.module.css new file mode 100644 index 000000000..b16fdb597 --- /dev/null +++ b/packages/widgets/src/dawarich-statistics/component.module.css @@ -0,0 +1,10 @@ +.statItem { + padding: 12px; + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + transition: all 0.2s ease; + + &:hover { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } +} diff --git a/packages/widgets/src/dawarich-statistics/component.tsx b/packages/widgets/src/dawarich-statistics/component.tsx new file mode 100644 index 000000000..fac8268a9 --- /dev/null +++ b/packages/widgets/src/dawarich-statistics/component.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Group, Stack, Text } from "@mantine/core"; +import { IconMap, IconMapPin, IconRoute, IconWorld, IconBuildingSkyscraper } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.css"; + +export default function DawarichStatisticsWidget({ + integrationIds, + options, +}: WidgetComponentProps<"dawarichStatistics">) { + const t = useI18n(); + const { data: stats } = clientApi.widget.dawarich.getStatistics.useQuery( + { + integrationId: integrationIds[0] ?? "", + }, + { staleTime: 5 * 60 * 1000 }, + ); + + if (!stats) return ; + + return ( + + {options.showTotalDistance && ( + } + label={t("widget.dawarichStatistics.totalDistance")} + value={`${stats.totalDistanceKm.toLocaleString(undefined, { maximumFractionDigits: 1 })} km`} + /> + )} + {options.showTotalPoints && ( + } + label={t("widget.dawarichStatistics.totalPoints")} + value={stats.totalPointsTracked.toLocaleString()} + /> + )} + {options.showReverseGeocoded && ( + } + label={t("widget.dawarichStatistics.reverseGeocoded")} + value={stats.totalReverseGeocodedPoints.toLocaleString()} + /> + )} + {options.showCountries && ( + } + label={t("widget.dawarichStatistics.countries")} + value={stats.totalCountriesVisited.toLocaleString()} + /> + )} + {options.showCities && ( + } + label={t("widget.dawarichStatistics.cities")} + value={stats.totalCitiesVisited.toLocaleString()} + /> + )} + + ); +} + +interface StatItemProps { + icon: React.ReactNode; + label: string; + value: string | number; +} + +function StatItem({ icon, label, value }: StatItemProps) { + return ( + + + {icon} + + {label} + + + + {value} + + + ); +} diff --git a/packages/widgets/src/dawarich-statistics/index.ts b/packages/widgets/src/dawarich-statistics/index.ts new file mode 100644 index 000000000..484dfbd1e --- /dev/null +++ b/packages/widgets/src/dawarich-statistics/index.ts @@ -0,0 +1,19 @@ +import { IconChartBar } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("dawarichStatistics", { + icon: IconChartBar, + supportedIntegrations: ["dawarich"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + showTotalDistance: factory.switch({ defaultValue: true }), + showTotalPoints: factory.switch({ defaultValue: true }), + showReverseGeocoded: factory.switch({ defaultValue: true }), + showCountries: factory.switch({ defaultValue: true }), + showCities: factory.switch({ defaultValue: true }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index ca8a5f62f..1c8d9fb6b 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -60,6 +60,8 @@ import * as beszelSystemStats from "./beszel-system-stats"; import * as video from "./video"; import * as weather from "./weather"; import * as customApi from "./custom-api"; +import * as dawarichStatistics from "./dawarich-statistics"; +import * as dawarichPlaces from "./dawarich-places"; export type { WidgetDefinition, @@ -121,6 +123,8 @@ export const widgetImports = { beszelAlerts, beszelSystemStats, customApi, + dawarichStatistics, + dawarichPlaces, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports;