diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index 96b53c09..f34bb8fd 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -19,6 +19,7 @@ import { DialogTitle, DialogContent, DialogActions, + Divider, CircularProgress, Menu, ListItemText, @@ -58,7 +59,7 @@ import { householdNow, isExpired } from '../utils/choreExpiry.js'; import useRoutinesToday from '../utils/useRoutinesToday.js'; import { shouldPersistSettings } from '../utils/widgetSettingsPersist'; import { parseVacationModeSetting } from '../utils/interfaceSettings.js'; -import { addDays, choreDays } from '../utils/choreDays.js'; +import { addDays, choreDays, displayDays } from '../utils/choreDays.js'; import { getServerTimezoneSync } from '../utils/timezone.js'; import ChoreDayMark from './ChoreDayMark.jsx'; import ChoreHistoryDialog from './ChoreHistoryDialog.jsx'; @@ -71,6 +72,9 @@ const DEFAULT_SHOW_BONUS_CHORES = true; const DEFAULT_SOUND_ENABLED = true; // Off by default: the 7-day row under each chore is opt-in per display. const DEFAULT_SHOW_WEEK = false; +// On by default: the 7-day row shows missed days as red crosses. Turn it off +// per display to draw missed days as plain "nothing logged" instead. +const DEFAULT_SHOW_MISSED_MARKS = true; const WEEK_DAYS = 7; // Frozen: it is shared by every instance's initial state and by the // no-stored-settings snapshot, so an in-place mutation would corrupt both. @@ -156,6 +160,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { }); const [soundEnabled, setSoundEnabled] = useState(DEFAULT_SOUND_ENABLED); const [showWeek, setShowWeek] = useState(DEFAULT_SHOW_WEEK); + const [showMissedMarks, setShowMissedMarks] = useState(DEFAULT_SHOW_MISSED_MARKS); // Per-device: which real users this display hides. Bonus pseudo-user (id 0) // is not a real user and is not offered in the selector — the "Bonus Chores" // column already has its own toggle. @@ -258,11 +263,15 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { showWeek: choreSettings && typeof choreSettings.showWeek === 'boolean' ? choreSettings.showWeek : DEFAULT_SHOW_WEEK, + showMissedMarks: choreSettings && typeof choreSettings.showMissedMarks === 'boolean' + ? choreSettings.showMissedMarks + : DEFAULT_SHOW_MISSED_MARKS, }; setShowBonusChores(adopted.showBonusChores); setSoundEnabled(adopted.soundEnabled); setHiddenUserIds(adopted.hiddenUserIds); setShowWeek(adopted.showWeek); + setShowMissedMarks(adopted.showMissedMarks); } catch (error) { console.error('Error loading chore widget settings:', error); } finally { @@ -291,7 +300,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { return; } - const current = { showBonusChores, soundEnabled, hiddenUserIds, showWeek }; + const current = { showBonusChores, soundEnabled, hiddenUserIds, showWeek, showMissedMarks }; // The load itself is not an edit. Without this the effect writes back what // it just read on every mount — and on every tab change that remounts the // widget, which on a rotating display is a write a minute. @@ -311,7 +320,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { }, 250); return () => clearTimeout(timeoutId); - }, [API_DEVICE_URL, deviceSettingsLoaded, showBonusChores, soundEnabled, hiddenUserIds, showWeek]); + }, [API_DEVICE_URL, deviceSettingsLoaded, showBonusChores, soundEnabled, hiddenUserIds, showWeek, showMissedMarks]); useEffect(() => { const onUsersUpdated = () => { @@ -1461,9 +1470,13 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { )} {/* Its own line under the title and the button: beside the button, seven marks don't fit a narrow column and push the button out. */} + {/* The 7-day row. displayDays hides the red missed crosses when + the display's "show missed days" setting is off — missed days + draw as plain "nothing logged" instead. The History dialog + still shows the real record. */} {showWeek && schedule.user_id ? ( - {choreDays({ rows: weekHistory, schedule, userId: schedule.user_id, today: getTodayDateString(), count: WEEK_DAYS, vacation, timeZone: getServerTimezoneSync() }) + {displayDays(choreDays({ rows: weekHistory, schedule, userId: schedule.user_id, today: getTodayDateString(), count: WEEK_DAYS, vacation, timeZone: getServerTimezoneSync() }), { showMissed: showMissedMarks }) .map((day) => ( ))} @@ -2243,6 +2256,24 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { })} )} + + + {t('chores:history.weekHeading')} + + setShowMissedMarks(event.target.checked)} + /> + } + label={t('chores:history.showMissedMarks')} + /> + + {t('chores:history.showMissedMarksHelper')} + diff --git a/client/src/i18n/locales/en/chores.json b/client/src/i18n/locales/en/chores.json index 43525a33..883b0cb6 100644 --- a/client/src/i18n/locales/en/chores.json +++ b/client/src/i18n/locales/en/chores.json @@ -438,6 +438,9 @@ "menuItem": "History", "showWeek": "Show the last 7 days", "hideWeek": "Hide the last 7 days", + "weekHeading": "Last 7 days", + "showMissedMarks": "Show missed days", + "showMissedMarksHelper": "When off, a missed day looks like any other day with nothing logged. The record in History is unchanged.", "weekAria": "Last 7 days of {{title}}", "lastDays": "Last {{count}} days", "streak_one": "{{count}} day in a row", diff --git a/client/src/i18n/locales/es/chores.json b/client/src/i18n/locales/es/chores.json index 4310748d..c6ab0c5c 100644 --- a/client/src/i18n/locales/es/chores.json +++ b/client/src/i18n/locales/es/chores.json @@ -438,6 +438,9 @@ "menuItem": "Historial", "showWeek": "Mostrar los últimos 7 días", "hideWeek": "Ocultar los últimos 7 días", + "weekHeading": "Últimos 7 días", + "showMissedMarks": "Mostrar los días no hechos", + "showMissedMarksHelper": "Cuando está apagado, un día no hecho se ve como cualquier otro día sin registro. El historial no cambia.", "weekAria": "Últimos 7 días de {{title}}", "lastDays": "Últimos {{count}} días", "streak_one": "{{count}} día seguido", diff --git a/client/src/utils/choreDays.js b/client/src/utils/choreDays.js index 0c940de9..4407e4e1 100644 --- a/client/src/utils/choreDays.js +++ b/client/src/utils/choreDays.js @@ -132,6 +132,17 @@ export function choreDays({ rows, schedule, userId, today, count = 7, vacation = return days; } +/** + * The 7-day row's display states. With missed marks hidden, a missed day + * draws as 'open' (due, nothing logged) instead of the red cross — no + * negative mark for a day that didn't get done. The underlying days are + * unchanged, so the History dialog and the streak still see the miss. + */ +export function displayDays(days, { showMissed = true } = {}) { + if (showMissed) return days; + return days.map((day) => (day.state === 'missed' ? { ...day, state: 'open' } : day)); +} + /** * Consecutive due days done, counting back from today. Today counts once done * and never breaks the run while still open; days off and vacation days are diff --git a/client/src/utils/choreDays.test.js b/client/src/utils/choreDays.test.js index 7b85deea..0dcee7e1 100644 --- a/client/src/utils/choreDays.test.js +++ b/client/src/utils/choreDays.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { addDays, cronFiresOn, missesTracked, dueOn, choreDays, streakOf, summarize, rowsFor } from './choreDays.js'; +import { addDays, cronFiresOn, missesTracked, dueOn, choreDays, displayDays, streakOf, summarize, rowsFor } from './choreDays.js'; // 2026-10-20 is a Tuesday. const TODAY = '2026-10-20'; @@ -120,6 +120,27 @@ describe('choreDays', () => { }); }); +describe('displayDays', () => { + const d = (...s) => s.map((state, i) => ({ date: `2026-10-1${i}`, state })); + it('hides missed days as open when missed marks are off', () => { + expect(displayDays(d('done', 'missed', 'open', 'missed'), { showMissed: false }).map((day) => day.state)) + .toEqual(['done', 'open', 'open', 'open']); + }); + it('keeps missed days when missed marks are on', () => { + expect(displayDays(d('done', 'missed'), { showMissed: true }).map((day) => day.state)) + .toEqual(['done', 'missed']); + }); + it('defaults to showing missed days and leaves other states alone', () => { + expect(displayDays(d('missed', 'today', 'vacation', 'off')).map((day) => day.state)) + .toEqual(['missed', 'today', 'vacation', 'off']); + }); + it('does not mutate the input days', () => { + const days = d('missed'); + displayDays(days, { showMissed: false }); + expect(days[0].state).toBe('missed'); + }); +}); + describe('streakOf', () => { const d = (...s) => s.map((state) => ({ state })); it('skips days off and an open today', () => {