diff --git a/client/src/components/CalendarWidget.jsx b/client/src/components/CalendarWidget.jsx
index 4a588f2..4b00b2a 100644
--- a/client/src/components/CalendarWidget.jsx
+++ b/client/src/components/CalendarWidget.jsx
@@ -1,6 +1,6 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { Card, Typography, Box, List, ListItem, ListItemText, Dialog, DialogTitle, DialogContent, DialogActions, Button, IconButton, Popover, ToggleButton, ToggleButtonGroup, TextField, Switch, Checkbox, FormControlLabel, Select, MenuItem, FormControl, InputLabel, Chip, Divider, CircularProgress, Alert, Tooltip } from '@mui/material';
-import { Settings, ViewModule, ViewWeek, ChevronLeft, ChevronRight, Add, Delete, Edit, Refresh, Remove, Sync, Schedule } from '@mui/icons-material';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { Card, Typography, Box, List, ListItem, ListItemText, Dialog, DialogTitle, DialogContent, DialogActions, Button, ButtonBase, IconButton, Popover, ToggleButton, ToggleButtonGroup, TextField, Switch, Checkbox, FormControlLabel, Select, MenuItem, FormControl, InputLabel, Chip, Divider, CircularProgress, Alert, Tooltip } from '@mui/material';
+import { Settings, ViewModule, ViewWeek, ChevronLeft, ChevronRight, Add, Delete, Edit, Refresh, Remove, Sync, Schedule, Today } from '@mui/icons-material';
import moment from 'moment';
import { SketchPicker } from 'react-color';
import axios from 'axios';
@@ -10,6 +10,14 @@ import { getDeviceApiBase } from '../utils/deviceName.js';
import { getEventPillPalette, getPreferredColorMode } from '../utils/colorContrast.js';
import { buildMergedDotColors, buildMergedDotBackground, describeMergedCalendars } from '../utils/calendarMergeColors.js';
import useIsMobile from '../hooks/useIsMobile.js';
+import { usePageVisibility } from '../hooks/useScreenActivity.js';
+import {
+ DEFAULT_IDLE_RETURN_MINUTES,
+ MAX_IDLE_RETURN_MINUTES,
+ idleReturnTimeoutMs,
+ isSameLocalCalendarDay,
+ normalizeIdleReturnMinutes,
+} from '../utils/calendarIdleReturn.js';
import MonthDayCell from './MonthDayCell.jsx';
import ColorPickerPopover from './ColorPickerPopover.jsx';
import {
@@ -19,6 +27,7 @@ import {
formatShortDateTime,
formatFullDate,
formatMonthYear,
+ formatMonthShortYear,
formatWeekdayShort,
formatMonthShort,
formatDayOfMonth,
@@ -208,6 +217,14 @@ const CalendarWidget = ({
const [viewMode, setViewMode] = useState('month');
const [currentDate, setCurrentDate] = useState(new Date());
const [eventColors, setEventColors] = useState({ ...DEFAULT_CALENDAR_EVENT_COLORS });
+ // Idle auto-return: 0 means DISABLED, not "return instantly".
+ // The raw input string lets the settings field accept an empty box and
+ // parse it as disabled without snapping back to a number on every keystroke.
+ const [idleReturnMinutes, setIdleReturnMinutes] = useState(DEFAULT_IDLE_RETURN_MINUTES);
+ const [idleReturnMinutesInput, setIdleReturnMinutesInput] = useState(String(DEFAULT_IDLE_RETURN_MINUTES));
+ const [activityTick, setActivityTick] = useState(0);
+ const pageVisible = usePageVisibility();
+ const markActivity = useCallback(() => setActivityTick((tick) => tick + 1), []);
const [displaySettings, setDisplaySettings] = useState({ ...DEFAULT_CALENDAR_DISPLAY_SETTINGS });
const [dayOfWeekSettings, setDayOfWeekSettings] = useState({ ...DEFAULT_CALENDAR_DAY_OF_WEEK_SETTINGS });
const [calendarSettingsLoaded, setCalendarSettingsLoaded] = useState(false);
@@ -311,6 +328,12 @@ const CalendarWidget = ({
...settings.eventColors,
});
}
+
+ if (Object.prototype.hasOwnProperty.call(settings, 'idleReturnMinutes')) {
+ const normalized = normalizeIdleReturnMinutes(settings.idleReturnMinutes);
+ setIdleReturnMinutes(normalized);
+ setIdleReturnMinutesInput(normalized > 0 ? String(normalized) : '');
+ }
} catch (error) {
console.error('Error loading calendar widget settings:', error);
} finally {
@@ -331,6 +354,7 @@ const CalendarWidget = ({
await axios.patch(`${API_DEVICE_URL}/settings`, {
calendarWidgetSettings: {
eventColors,
+ idleReturnMinutes,
},
});
} catch (error) {
@@ -344,6 +368,7 @@ const CalendarWidget = ({
API_DEVICE_URL,
calendarSettingsLoaded,
eventColors,
+ idleReturnMinutes,
]);
useEffect(() => {
@@ -434,6 +459,38 @@ const CalendarWidget = ({
return () => clearTimeout(timeoutId);
}, [API_DEVICE_URL, activeTab, activeTabConfigJson, dayOfWeekSettings, displaySettings]);
+ const isViewingToday = isSameLocalCalendarDay(currentDate, new Date());
+
+ const goToToday = () => {
+ const now = new Date();
+ if (!isSameLocalCalendarDay(currentDate, now)) {
+ setCurrentDate(now);
+ }
+ markActivity();
+ };
+
+ // Idle auto-return. Any user interaction bumps activityTick,
+ // which restarts the countdown; unmounting or a config change clears the
+ // pending timeout via the cleanup below (a leak on a wall display that runs
+ // for months would be a real bug, not a theoretical one). While the browser
+ // tab is hidden we bail out entirely — the same page-visibility signal the
+ // rest of the app already uses — so the calendar doesn't silently jump
+ // around behind another tab. Auto-return moves the date only; viewMode is
+ // preserved so a household that deliberately chose week view isn't flipped
+ // back to month as a second surprise on top of the date jump.
+ useEffect(() => {
+ const timeoutMs = idleReturnTimeoutMs(idleReturnMinutes);
+ if (!timeoutMs) return undefined;
+ if (!pageVisible) return undefined;
+ if (isViewingToday) return undefined;
+
+ const timeoutId = setTimeout(() => {
+ setCurrentDate(new Date());
+ }, timeoutMs);
+
+ return () => clearTimeout(timeoutId);
+ }, [activityTick, idleReturnMinutes, pageVisible, isViewingToday]);
+
const fetchCalendarSources = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/api/calendar-sources`);
@@ -805,6 +862,7 @@ const CalendarWidget = ({
const openCreateEventDialog = () => {
const googleSources = getGoogleSources();
if (googleSources.length === 0) return;
+ markActivity();
const baseDay = selectedDate ? moment(selectedDate) : moment();
const start = baseDay.clone().hour(9).minute(0).second(0);
const end = start.clone().add(1, 'hour');
@@ -821,6 +879,7 @@ const CalendarWidget = ({
};
const openEditEventDialog = (event) => {
+ markActivity();
const allDay = !!event.all_day;
const startStr = allDay
? moment(event.start).format('YYYY-MM-DD')
@@ -900,6 +959,7 @@ const CalendarWidget = ({
};
const handleSelectSlot = ({ start }) => {
+ markActivity();
const selectedDay = moment(start).startOf('day');
const dayDate = selectedDay.toDate();
const dayEvents = events
@@ -916,6 +976,7 @@ const CalendarWidget = ({
};
const handleSelectEvent = (event) => {
+ markActivity();
const selectedDay = moment(event.start).startOf('day');
const dayDate = selectedDay.toDate();
const dayEvents = events
@@ -1051,6 +1112,7 @@ const CalendarWidget = ({
};
const handleSettingsClick = (event) => {
+ markActivity();
setSettingsAnchor(event.currentTarget);
};
@@ -1070,6 +1132,7 @@ const CalendarWidget = ({
if (newViewMode === null || !TAB_CALENDAR_VIEW_MODES.has(newViewMode)) return;
// Optimistic UI: switch instantly, then persist in the background.
+ markActivity();
setViewMode(newViewMode);
void persistViewModeForTab(activeTab, newViewMode);
};
@@ -1079,6 +1142,7 @@ const CalendarWidget = ({
};
const handlePreviousPeriod = () => {
+ markActivity();
if (viewMode === 'month') {
const newDate = new Date(currentDate);
if (isRollingMonthView) {
@@ -1097,6 +1161,7 @@ const CalendarWidget = ({
};
const handleNextPeriod = () => {
+ markActivity();
if (viewMode === 'month') {
const newDate = new Date(currentDate);
if (isRollingMonthView) {
@@ -1137,7 +1202,7 @@ const CalendarWidget = ({
return formatDateRangeLabel(start, start.clone().add(monthViewWeeksToShow * 7 - 1, 'days'));
}
- return formatMonthYear(currentDate);
+ return isMobile ? formatMonthShortYear(currentDate) : formatMonthYear(currentDate);
} else {
const startOfWeek = getWeekStartDate();
const endOfWeek = startOfWeek.clone().add(6, 'days');
@@ -1207,9 +1272,28 @@ const CalendarWidget = ({
>
-
- 📅 {getCurrentPeriodLabel()}
-
+ {/* The period label doubles as the go-to-today control: on a phone this is the only Today affordance, and on
+ desktop the icon button below keeps it discoverable. */}
+
+
+
+ {isMobile ? '' : '📅 '}{getCurrentPeriodLabel()}
+
+
+
+
+ {/* Desktop-only redundant affordance: on phones the label above is
+ the control, but a tappable heading isn't self-evidently
+ tappable, so keep the icon visible from `sm` up. */}
+
+
+
+
+ {t('calendar:settings.idleReturnHeading')}
+
+ {t('calendar:settings.idleReturnHelp')}
+
+ {
+ const raw = e.target.value;
+ setIdleReturnMinutesInput(raw);
+ setIdleReturnMinutes(normalizeIdleReturnMinutes(raw));
+ }}
+ slotProps={{
+ htmlInput: {
+ min: 0,
+ max: MAX_IDLE_RETURN_MINUTES,
+ step: 1,
+ inputMode: 'numeric',
+ },
+ }}
+ helperText={t('calendar:settings.idleReturnDisabledHint')}
+ sx={{ mb: 2 }}
+ />
+
+
+
{t('calendar:settings.defaultColors')}
diff --git a/client/src/i18n/locales/en/calendar.json b/client/src/i18n/locales/en/calendar.json
index c7de8ff..a3b2b3f 100644
--- a/client/src/i18n/locales/en/calendar.json
+++ b/client/src/i18n/locales/en/calendar.json
@@ -9,7 +9,8 @@
"weekView": "week view",
"addEvent": "Add event",
"editEvent": "Edit event",
- "deleteEvent": "Delete event"
+ "deleteEvent": "Delete event",
+ "goToToday": "Go to today"
},
"event": {
"calendar": "Calendar",
@@ -70,7 +71,11 @@
"defaultColors": "Default Event Colors",
"eventBackground": "Event Background Color",
"eventText": "Event Text Color",
- "resetToDefault": "Reset to Default"
+ "resetToDefault": "Reset to Default",
+ "idleReturnHeading": "Auto-return to today",
+ "idleReturnHelp": "After this many minutes without any interaction, the calendar jumps back to today. The view (month or week) is left alone.",
+ "idleReturnMinutes": "Minutes idle",
+ "idleReturnDisabledHint": "Set to 0 (or leave empty) to turn auto-return off."
},
"refresh": {
"disabled": "Disabled",
diff --git a/client/src/i18n/locales/es/calendar.json b/client/src/i18n/locales/es/calendar.json
index fa0017e..e894538 100644
--- a/client/src/i18n/locales/es/calendar.json
+++ b/client/src/i18n/locales/es/calendar.json
@@ -9,7 +9,8 @@
"weekView": "vista de semana",
"addEvent": "Añadir evento",
"editEvent": "Editar evento",
- "deleteEvent": "Eliminar evento"
+ "deleteEvent": "Eliminar evento",
+ "goToToday": "Ir a hoy"
},
"event": {
"calendar": "Calendario",
@@ -70,7 +71,11 @@
"defaultColors": "Colores predeterminados de los eventos",
"eventBackground": "Color de fondo del evento",
"eventText": "Color del texto del evento",
- "resetToDefault": "Restablecer valores predeterminados"
+ "resetToDefault": "Restablecer valores predeterminados",
+ "idleReturnHeading": "Volver a hoy automáticamente",
+ "idleReturnHelp": "Tras estos minutos sin ninguna interacción, el calendario vuelve al día de hoy. La vista (mes o semana) se mantiene.",
+ "idleReturnMinutes": "Minutos sin actividad",
+ "idleReturnDisabledHint": "Pon 0 (o déjalo vacío) para desactivarlo."
},
"refresh": {
"disabled": "Desactivado",
diff --git a/client/src/utils/calendarIdleReturn.js b/client/src/utils/calendarIdleReturn.js
new file mode 100644
index 0000000..917c696
--- /dev/null
+++ b/client/src/utils/calendarIdleReturn.js
@@ -0,0 +1,43 @@
+// Helpers behind the calendar's "return to today after idle" feature. Wall
+// displays that sit on last month look current and are not; a bounded idle
+// timer swings the view back to today after the household has stopped
+// touching the calendar for a while.
+
+export const DEFAULT_IDLE_RETURN_MINUTES = 20;
+export const MIN_IDLE_RETURN_MINUTES = 1;
+export const MAX_IDLE_RETURN_MINUTES = 240;
+
+// Parses the configured minutes into an integer >= 0. A value of 0 (or an
+// empty / negative / non-numeric input) means DISABLED — auto-return is off
+// entirely rather than "return immediately", which would make the calendar
+// impossible to navigate away from today.
+export const normalizeIdleReturnMinutes = (raw) => {
+ if (raw === '' || raw === null || raw === undefined) return 0;
+ const parsed = typeof raw === 'number' ? raw : Number(raw);
+ if (!Number.isFinite(parsed)) return 0;
+ const floored = Math.floor(parsed);
+ if (floored <= 0) return 0;
+ return Math.min(MAX_IDLE_RETURN_MINUTES, Math.max(MIN_IDLE_RETURN_MINUTES, floored));
+};
+
+export const isIdleReturnEnabled = (minutes) => normalizeIdleReturnMinutes(minutes) > 0;
+
+// Millisecond timeout to hand to setTimeout, or null when disabled — callers
+// use null to skip arming the timer at all.
+export const idleReturnTimeoutMs = (minutes) => {
+ const n = normalizeIdleReturnMinutes(minutes);
+ return n > 0 ? n * 60 * 1000 : null;
+};
+
+// True when two Date-likes represent the same local calendar day. Guards the
+// button's disabled state and prevents the idle timer from firing a no-op
+// setCurrentDate when the widget is already on today.
+export const isSameLocalCalendarDay = (a, b) => {
+ if (a == null || b == null) return false;
+ const da = a instanceof Date ? a : new Date(a);
+ const db = b instanceof Date ? b : new Date(b);
+ if (Number.isNaN(da.getTime()) || Number.isNaN(db.getTime())) return false;
+ return da.getFullYear() === db.getFullYear()
+ && da.getMonth() === db.getMonth()
+ && da.getDate() === db.getDate();
+};
diff --git a/client/src/utils/calendarIdleReturn.test.js b/client/src/utils/calendarIdleReturn.test.js
new file mode 100644
index 0000000..5892bce
--- /dev/null
+++ b/client/src/utils/calendarIdleReturn.test.js
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest';
+import {
+ DEFAULT_IDLE_RETURN_MINUTES,
+ MAX_IDLE_RETURN_MINUTES,
+ MIN_IDLE_RETURN_MINUTES,
+ normalizeIdleReturnMinutes,
+ isIdleReturnEnabled,
+ idleReturnTimeoutMs,
+ isSameLocalCalendarDay,
+} from './calendarIdleReturn.js';
+
+describe('normalizeIdleReturnMinutes', () => {
+ it('treats empty, null, undefined, NaN, and non-numeric as disabled (0)', () => {
+ expect(normalizeIdleReturnMinutes('')).toBe(0);
+ expect(normalizeIdleReturnMinutes(null)).toBe(0);
+ expect(normalizeIdleReturnMinutes(undefined)).toBe(0);
+ expect(normalizeIdleReturnMinutes('abc')).toBe(0);
+ expect(normalizeIdleReturnMinutes(Number.NaN)).toBe(0);
+ expect(normalizeIdleReturnMinutes(Infinity)).toBe(0);
+ });
+
+ it('treats 0 and negative values as disabled', () => {
+ expect(normalizeIdleReturnMinutes(0)).toBe(0);
+ expect(normalizeIdleReturnMinutes('0')).toBe(0);
+ expect(normalizeIdleReturnMinutes(-5)).toBe(0);
+ expect(normalizeIdleReturnMinutes('-5')).toBe(0);
+ });
+
+ it('clamps to [MIN, MAX] and floors fractional inputs', () => {
+ expect(normalizeIdleReturnMinutes(20)).toBe(20);
+ expect(normalizeIdleReturnMinutes('45')).toBe(45);
+ expect(normalizeIdleReturnMinutes(20.9)).toBe(20);
+ expect(normalizeIdleReturnMinutes(1)).toBe(MIN_IDLE_RETURN_MINUTES);
+ expect(normalizeIdleReturnMinutes(9999)).toBe(MAX_IDLE_RETURN_MINUTES);
+ });
+});
+
+describe('isIdleReturnEnabled', () => {
+ it('is false only when normalization produces 0', () => {
+ expect(isIdleReturnEnabled(0)).toBe(false);
+ expect(isIdleReturnEnabled('')).toBe(false);
+ expect(isIdleReturnEnabled(null)).toBe(false);
+ expect(isIdleReturnEnabled(DEFAULT_IDLE_RETURN_MINUTES)).toBe(true);
+ expect(isIdleReturnEnabled(1)).toBe(true);
+ });
+});
+
+describe('idleReturnTimeoutMs', () => {
+ it('returns null when disabled, and minutes-in-ms otherwise', () => {
+ expect(idleReturnTimeoutMs(0)).toBeNull();
+ expect(idleReturnTimeoutMs('')).toBeNull();
+ expect(idleReturnTimeoutMs(20)).toBe(20 * 60 * 1000);
+ expect(idleReturnTimeoutMs('1')).toBe(60 * 1000);
+ });
+});
+
+describe('isSameLocalCalendarDay', () => {
+ it('is true for two Dates on the same local day, regardless of time', () => {
+ const morning = new Date(2026, 7, 28, 6, 30);
+ const evening = new Date(2026, 7, 28, 23, 59);
+ expect(isSameLocalCalendarDay(morning, evening)).toBe(true);
+ });
+
+ it('is false across a midnight boundary', () => {
+ const late = new Date(2026, 7, 28, 23, 59);
+ const early = new Date(2026, 7, 29, 0, 1);
+ expect(isSameLocalCalendarDay(late, early)).toBe(false);
+ });
+
+ it('coerces Date-likes and rejects invalid inputs', () => {
+ expect(isSameLocalCalendarDay(null, new Date())).toBe(false);
+ expect(isSameLocalCalendarDay(new Date(), undefined)).toBe(false);
+ expect(isSameLocalCalendarDay('not a date', new Date())).toBe(false);
+ expect(
+ isSameLocalCalendarDay('2026-08-28T10:00:00', '2026-08-28T22:00:00'),
+ ).toBe(true);
+ });
+});
diff --git a/client/src/utils/dateUtils.js b/client/src/utils/dateUtils.js
index e935775..c8be91d 100644
--- a/client/src/utils/dateUtils.js
+++ b/client/src/utils/dateUtils.js
@@ -83,6 +83,9 @@ export const formatFullDate = (value) =>
/** "August 2026" — month and year, for calendar headers. */
export const formatMonthYear = (value) => formatWith({ year: 'numeric', month: 'long' }, value);
+/** "Aug 2026" — short month and year, for narrow calendar headers. */
+export const formatMonthShortYear = (value) => formatWith({ year: 'numeric', month: 'short' }, value);
+
/** "Thu" — abbreviated weekday. */
export const formatWeekdayShort = (value) => formatWith({ weekday: 'short' }, value);
diff --git a/docs/reference/features.md b/docs/reference/features.md
index 66bc5b6..f1b4794 100644
--- a/docs/reference/features.md
+++ b/docs/reference/features.md
@@ -188,6 +188,13 @@ in `server/index.js`.
calendar colors, so it keeps answering "which calendars is this on?"
- **Per-event Google colors** (PR #133): an event individually recolored in
Google keeps that color in HomeGlow instead of inheriting its calendar's.
+- **Return to today**: the period label is a button — tap it to jump back. Inert
+ when already there. Desktop also gets a 📅 button; on a phone the header has no
+ room for one.
+- **Idle auto-return** (`calendarWidgetSettings.idleReturnMinutes`, per device,
+ default 20 minutes, 0 disables): returns to today after that long without
+ interaction, so a wall display left on last month stops looking current. Resets
+ the date only, not the view.
Sync resolves the event's `colorId` to a hex through Google's `/colors`
palette (cached 24h) and stores it in the existing `raw_data` column, which
`getCachedEvents` surfaces as `event_color`. Every view prefers