feat: expand utility widgets

This commit is contained in:
ajnart committed 2026-08-20 15:16:26 +02:00
1 parent a2219b72f6
commit 56c2c4dcc0
82 files changed
+7520 -740

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
"use client";
import {
Badge,
Box,
Group,
Paper,
Progress,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedDate } from "../common/locale";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import { getTodayLayout, getTodayStats } from "./date-stats";
const formatProgress = (value: number) => Math.round(value);
export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) {
const now = useWidgetNow("minute");
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.today");
if (now === null) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const stats = getTodayStats(now, options.weekConvention, locale);
if (displayMode === "advanced") {
return <AdvancedToday now={now} locale={locale} options={options} stats={stats} />;
}
const layout = getTodayLayout(width, height);
const weekday = formatLocalizedDate(now, locale, { weekday: "long" });
const month = formatLocalizedDate(now, locale, { month: "long" });
const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" });
if (layout.tiny) {
return (
<Stack h="100%" align="center" justify="center" gap={0} px="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday} · {month}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={34} fw={700} lh={1}>
{now.getDate()}
</Text>
</Stack>
);
}
return (
<Stack h="100%" justify="center" gap="xs" px="md" py="sm" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={layout.showFullDate ? "lg" : 30} fw={700} truncate>
{layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
</Text>
</Stack>
{options.showYearProgress && (
<Box>
<RingProgress
aria-hidden
size={54}
thickness={5}
sections={[{ value: stats.yearProgress, color: "blue" }]}
label={
<Text size="xs" ta="center" fw={700}>
{formatProgress(stats.yearProgress)}%
</Text>
}
/>
<VisuallyHidden>
<progress value={stats.yearProgress} max={100}>
{t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
</progress>
</VisuallyHidden>
</Box>
)}
</Group>
{layout.showStats && (
<Group gap={6} wrap="wrap">
{options.showWeekNumber && <Badge variant="light">{t("weekNumber", { week: stats.weekNumber })}</Badge>}
{options.showDayOfYear && (
<Badge variant="light">{t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}</Badge>
)}
{layout.showQuarter && options.showQuarter && (
<Badge variant="light">{t("quarter", { quarter: stats.quarter })}</Badge>
)}
{layout.showQuarter && (
<Text size="xs" c="dimmed">
{t("daysAfterToday", { days: stats.daysAfterToday })}
</Text>
)}
</Group>
)}
{layout.showMonthProgress && <ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />}
</Stack>
);
}
interface AdvancedTodayProps {
now: Date;
locale: string;
options: WidgetComponentProps<"today">["options"];
stats: ReturnType<typeof getTodayStats>;
}
const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => {
const t = useScopedI18n("widget.today");
const numberFormatter = new Intl.NumberFormat(locale);
const visibleMetrics = [
options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) },
options.showDayOfYear && {
label: t("advanced.dayOfYear"),
value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`,
},
options.showQuarter && { label: t("advanced.quarter"), value: `Q${stats.quarter}` },
{ label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) },
].filter((metric): metric is { label: string; value: string } => Boolean(metric));
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Stack gap={0}>
<Text component="time" dateTime={toDateTime(now)} fz="xl" fw={700}>
{formatLocalizedDate(now, locale, { dateStyle: "full" })}
</Text>
{options.showWeekNumber && (
<Text size="sm" c="dimmed">
{t(`weekConvention.${options.weekConvention}`)}
</Text>
)}
</Stack>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{visibleMetrics.map((metric) => (
<Paper key={metric.label} withBorder p="sm">
<Text size="xs" c="dimmed">
{metric.label}
</Text>
<Text fz="xl" fw={700}>
{metric.value}
</Text>
</Paper>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<ProgressMetric label={t("weekProgressLabel")} value={stats.weekProgress} />
<ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />
{options.showYearProgress && <ProgressMetric label={t("yearProgressLabel")} value={stats.yearProgress} />}
</SimpleGrid>
<SimpleGrid cols={{ base: 3, sm: 4, md: 6 }} spacing="xs">
{Array.from({ length: 12 }, (_, monthIndex) => {
const date = new Date(now.getFullYear(), monthIndex, 1);
const isCurrent = monthIndex === stats.monthIndex;
return (
<Paper
key={monthIndex}
withBorder
p="xs"
bg={isCurrent ? "var(--mantine-primary-color-light)" : undefined}
>
<Text size="sm" fw={isCurrent ? 700 : 500} ta="center">
{formatLocalizedDate(date, locale, { month: "short" })}
</Text>
{isCurrent && (
<Text size="xs" c="dimmed" ta="center">
{now.getDate()}
</Text>
)}
</Paper>
);
})}
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
const roundedValue = formatProgress(value);
return (
<Box>
<Group justify="space-between" gap="xs" mb={3}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600}>
{roundedValue}%
</Text>
</Group>
<Progress value={value} aria-label={`${label}: ${roundedValue}%`} />
</Box>
);
};
const toDateTime = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getTodayLayout, getTodayStats } from "./date-stats";
describe("today date statistics", () => {
it("accounts for leap years", () => {
const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso");
expect(stats.dayOfYear).toBe(366);
expect(stats.daysInYear).toBe(366);
expect(stats.daysAfterToday).toBe(0);
});
it("uses the requested week convention", () => {
const date = new Date(2023, 0, 1, 12);
expect(getTodayStats(date, "iso").weekNumber).toBe(52);
expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1);
expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52);
});
it("returns bounded calendar progress", () => {
const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso");
expect(stats.quarter).toBe(3);
expect(stats.weekProgress).toBeGreaterThanOrEqual(0);
expect(stats.weekProgress).toBeLessThanOrEqual(100);
expect(stats.monthProgress).toBeGreaterThanOrEqual(0);
expect(stats.monthProgress).toBeLessThanOrEqual(100);
expect(stats.yearProgress).toBeGreaterThanOrEqual(0);
expect(stats.yearProgress).toBeLessThanOrEqual(100);
});
});
describe("today compact layout", () => {
it("keeps tiny widgets focused on the date", () => {
expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false });
});
it("reveals secondary information only when both axes have room", () => {
expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false });
expect(getTodayLayout(300, 180)).toEqual({
tiny: false,
showFullDate: true,
showStats: true,
showQuarter: true,
showMonthProgress: true,
});
});
});
+172
View File
@@ -0,0 +1,172 @@
import dayjs from "dayjs";
import dayOfYear from "dayjs/plugin/dayOfYear";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
dayjs.extend(dayOfYear);
dayjs.extend(quarterOfYear);
export type WeekConvention = "locale" | "iso";
export interface TodayStats {
dayOfYear: number;
daysInYear: number;
daysAfterToday: number;
monthIndex: number;
quarter: number;
weekNumber: number;
weekProgress: number;
monthProgress: number;
yearProgress: number;
}
const getProgress = (value: number, start: number, end: number) => {
if (end <= start) return 0;
return Math.min(100, Math.max(0, ((value - start) / (end - start)) * 100));
};
export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale = "en-US"): TodayStats => {
const value = dayjs(date);
const yearStart = value.startOf("year");
const nextYearStart = yearStart.add(1, "year");
const monthStart = value.startOf("month");
const nextMonthStart = monthStart.add(1, "month");
const weekRules = getWeekRules(weekConvention, locale);
const weekDay = value.day() === 0 ? 7 : value.day();
const daysSinceWeekStart = (weekDay - weekRules.firstDay + 7) % 7;
const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart);
const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7);
const currentDayOfYear = value.dayOfYear();
const daysInYear = nextYearStart.diff(yearStart, "day");
return {
dayOfYear: currentDayOfYear,
daysInYear,
daysAfterToday: daysInYear - currentDayOfYear,
monthIndex: value.month(),
quarter: value.quarter(),
weekNumber: getWeekNumber(date, weekRules),
weekProgress: getProgress(value.valueOf(), weekStart.getTime(), nextWeekStart.getTime()),
monthProgress: getProgress(value.valueOf(), monthStart.valueOf(), nextMonthStart.valueOf()),
yearProgress: getProgress(value.valueOf(), yearStart.valueOf(), nextYearStart.valueOf()),
};
};
interface WeekRules {
firstDay: number;
minimalDays: number;
}
interface LocaleWeekInfo {
firstDay: number;
minimalDays?: number;
}
type LocaleWithWeekInfo = Intl.Locale & {
weekInfo?: LocaleWeekInfo;
getWeekInfo?: () => LocaleWeekInfo;
};
const fourDayMinimumRegions = new Set([
"AD",
"AN",
"AT",
"AX",
"BE",
"BG",
"CH",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FJ",
"FO",
"FR",
"GF",
"GB",
"GG",
"GI",
"GP",
"GR",
"IE",
"IM",
"IS",
"IT",
"LI",
"LT",
"LU",
"MC",
"MQ",
"NL",
"NO",
"PL",
"PT",
"RE",
"RU",
"SE",
"SJ",
"SK",
"VA",
]);
const getWeekRules = (convention: WeekConvention, locale: string): WeekRules => {
if (convention === "iso") return { firstDay: 1, minimalDays: 4 };
try {
const localeInfo = new Intl.Locale(locale) as LocaleWithWeekInfo;
const weekInfo = localeInfo.weekInfo ?? localeInfo.getWeekInfo?.();
if (weekInfo) {
const region = localeInfo.maximize().region;
const fallbackMinimalDays = region && fourDayMinimumRegions.has(region) ? 4 : 1;
return { firstDay: weekInfo.firstDay, minimalDays: weekInfo.minimalDays ?? fallbackMinimalDays };
}
} catch {
// Invalid locales fall back to the common US week convention.
}
return { firstDay: 7, minimalDays: 1 };
};
const getWeekNumber = (date: Date, rules: WeekRules): number => {
const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
let weekYear = date.getFullYear();
let firstWeekStart = getFirstWeekStart(weekYear, rules);
if (dateDay < firstWeekStart) {
weekYear -= 1;
firstWeekStart = getFirstWeekStart(weekYear, rules);
} else {
const nextFirstWeekStart = getFirstWeekStart(weekYear + 1, rules);
if (dateDay >= nextFirstWeekStart) return 1;
}
return Math.floor((dateDay - firstWeekStart) / (7 * 86_400_000)) + 1;
};
const getFirstWeekStart = (year: number, rules: WeekRules): number => {
const januaryFirst = Date.UTC(year, 0, 1);
const sundayBasedWeekDay = new Date(januaryFirst).getUTCDay();
const weekDay = sundayBasedWeekDay === 0 ? 7 : sundayBasedWeekDay;
const daysBeforeJanuary = (weekDay - rules.firstDay + 7) % 7;
const daysInFirstWeek = 7 - daysBeforeJanuary;
if (daysInFirstWeek >= rules.minimalDays) return januaryFirst - daysBeforeJanuary * 86_400_000;
return januaryFirst + daysInFirstWeek * 86_400_000;
};
export interface TodayLayout {
tiny: boolean;
showFullDate: boolean;
showStats: boolean;
showQuarter: boolean;
showMonthProgress: boolean;
}
export const getTodayLayout = (width: number, height: number): TodayLayout => {
const tiny = width < 150 || height < 96;
return {
tiny,
showFullDate: !tiny && width >= 180,
showStats: !tiny && width >= 210 && height >= 115,
showQuarter: width >= 240 && height >= 145,
showMonthProgress: width >= 300 && height >= 170,
};
};
+24
View File
@@ -0,0 +1,24 @@
import { IconCalendarWeek } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("today", {
icon: IconCalendarWeek,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
weekConvention: factory.select({
defaultValue: "locale",
options: [
{ value: "locale", label: (t) => t("widget.today.option.weekConvention.options.locale") },
{ value: "iso", label: (t) => t("widget.today.option.weekConvention.options.iso") },
],
}),
showWeekNumber: factory.switch({ defaultValue: true }),
showDayOfYear: factory.switch({ defaultValue: true }),
showQuarter: factory.switch({ defaultValue: true }),
showYearProgress: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));