Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 133 additions & 7 deletions client/src/components/CalendarWidget.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand All @@ -19,6 +27,7 @@ import {
formatShortDateTime,
formatFullDate,
formatMonthYear,
formatMonthShortYear,
formatWeekdayShort,
formatMonthShort,
formatDayOfMonth,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -331,6 +354,7 @@ const CalendarWidget = ({
await axios.patch(`${API_DEVICE_URL}/settings`, {
calendarWidgetSettings: {
eventColors,
idleReturnMinutes,
},
});
} catch (error) {
Expand All @@ -344,6 +368,7 @@ const CalendarWidget = ({
API_DEVICE_URL,
calendarSettingsLoaded,
eventColors,
idleReturnMinutes,
]);

useEffect(() => {
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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');
Expand All @@ -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')
Expand Down Expand Up @@ -900,6 +959,7 @@ const CalendarWidget = ({
};

const handleSelectSlot = ({ start }) => {
markActivity();
const selectedDay = moment(start).startOf('day');
const dayDate = selectedDay.toDate();
const dayEvents = events
Expand All @@ -916,6 +976,7 @@ const CalendarWidget = ({
};

const handleSelectEvent = (event) => {
markActivity();
const selectedDay = moment(event.start).startOf('day');
const dayDate = selectedDay.toDate();
const dayEvents = events
Expand Down Expand Up @@ -1051,6 +1112,7 @@ const CalendarWidget = ({
};

const handleSettingsClick = (event) => {
markActivity();
setSettingsAnchor(event.currentTarget);
};

Expand All @@ -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);
};
Expand All @@ -1079,6 +1142,7 @@ const CalendarWidget = ({
};

const handlePreviousPeriod = () => {
markActivity();
if (viewMode === 'month') {
const newDate = new Date(currentDate);
if (isRollingMonthView) {
Expand All @@ -1097,6 +1161,7 @@ const CalendarWidget = ({
};

const handleNextPeriod = () => {
markActivity();
if (viewMode === 'month') {
const newDate = new Date(currentDate);
if (isRollingMonthView) {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -1207,9 +1272,28 @@ const CalendarWidget = ({
>
<ChevronLeft />
</IconButton>
<Typography variant="h6" sx={{ minWidth: '200px', textAlign: 'center' }}>
📅 {getCurrentPeriodLabel()}
</Typography>
{/* 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. */}
<Tooltip title={t('calendar:widget.goToToday')}>
<ButtonBase
onClick={goToToday}
aria-label={t('calendar:widget.goToToday')}
sx={{
minWidth: { xs: 0, sm: '200px' },
borderRadius: 1,
px: 1,
py: 0.5,
color: 'var(--text-color)',
'&:hover': {
backgroundColor: 'rgba(var(--accent-rgb), 0.08)',
},
}}
>
<Typography variant="h6" component="span" sx={{ textAlign: 'center' }}>
{isMobile ? '' : '📅 '}{getCurrentPeriodLabel()}
</Typography>
</ButtonBase>
</Tooltip>
<IconButton
onClick={handleNextPeriod}
size="small"
Expand All @@ -1218,6 +1302,19 @@ const CalendarWidget = ({
>
<ChevronRight />
</IconButton>
<Tooltip title={t('calendar:widget.goToToday')}>
{/* 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. */}
<IconButton
onClick={goToToday}
size="small"
sx={{ display: { xs: 'none', sm: 'inline-flex' }, color: 'var(--text-color)' }}
aria-label={t('calendar:widget.goToToday')}
>
<Today />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ToggleButtonGroup
Expand Down Expand Up @@ -2319,6 +2416,35 @@ const CalendarWidget = ({

<Divider sx={{ my: 2 }} />

<Typography variant="h6" sx={{ mb: 1 }}>{t('calendar:settings.idleReturnHeading')}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{t('calendar:settings.idleReturnHelp')}
</Typography>
<TextField
fullWidth
size="small"
type="number"
label={t('calendar:settings.idleReturnMinutes')}
value={idleReturnMinutesInput}
onChange={(e) => {
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 }}
/>

<Divider sx={{ my: 2 }} />

<Typography variant="h6" sx={{ mb: 2 }}>{t('calendar:settings.defaultColors')}</Typography>

<Box sx={{ mb: 3 }}>
Expand Down
9 changes: 7 additions & 2 deletions client/src/i18n/locales/en/calendar.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions client/src/i18n/locales/es/calendar.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions client/src/utils/calendarIdleReturn.js
Original file line number Diff line number Diff line change
@@ -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();
};
Loading