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