From f81d4a16b5647c59d9859133b413f58f71423288 Mon Sep 17 00:00:00 2001 From: jherforth Date: Thu, 13 Aug 2026 16:07:40 -0400 Subject: [PATCH 1/8] Drop react-big-calendar, which was never rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalendarWidget imported Calendar and built momentLocalizer(moment) but never used either — the widget draws its own month, week, and day views through MonthDayCell and getNext7Days. The import, the localizer, and the library's stylesheet were pure weight, and the only other `Calendar` matches in the file are unrelated identifiers (editingCalendar, handleSaveCalendar). Found while sizing i18n (#137): a react-big-calendar instance would have needed its own translated `messages` object, and the momentLocalizer is listed in #120 as a blocker for replacing moment. Neither is real. CalendarWidget chunk 237 KB -> 110 KB (-54%) total client JS 1,803 KB -> 1,676 KB (-127 KB) bundled CSS 18 KB -> 7 KB (react-big-calendar.css gone) node_modules 17 packages removed Verified: client 79/79, build clean, and a browser check confirming the calendar still renders its grid, weekday headers, and 31 demo event chips with zero console errors, including after toggling between month and week views. Co-Authored-By: Claude Fable 5 --- client/package-lock.json | 180 +---------------------- client/package.json | 1 - client/src/components/CalendarWidget.jsx | 4 - 3 files changed, 1 insertion(+), 184 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 87c7047..b07a2ca 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -16,7 +16,6 @@ "cron-parser": "^5.5.0", "moment": "^2.30.1", "react": "^19.2.1", - "react-big-calendar": "^1.19.4", "react-color": "2.19.3", "react-dom": "^19.2.1", "react-grid-layout": "^2.1.0", @@ -701,18 +700,6 @@ } } }, - "node_modules/@restart/hooks": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", - "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -1123,6 +1110,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1142,12 +1130,6 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@types/warning": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz", - "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -1431,11 +1413,6 @@ "node": ">=18" } }, - "node_modules/cldrjs": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz", - "integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA==" - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1627,18 +1604,6 @@ "node": ">=12" } }, - "node_modules/date-arithmetic": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz", - "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==", - "license": "MIT" - }, - "node_modules/dayjs": { - "version": "1.11.21", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", - "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1671,15 +1636,6 @@ "node": ">=0.4.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1690,16 +1646,6 @@ "node": ">=8" } }, - "node_modules/dom-helpers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz", - "integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.27.1", - "csstype": "^3.1.3" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1950,15 +1896,6 @@ "node": ">= 0.4" } }, - "node_modules/globalize": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/globalize/-/globalize-1.7.1.tgz", - "integrity": "sha512-PFymRL0PtitFOlSniuwwwNfkooi3cLQJo9Uke1+j1DsGfUkkHkwneImqVtGcqKI0TuzhAlHt7hAcgK324902HA==", - "license": "MIT", - "dependencies": { - "cldrjs": "^0.5.4" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2090,15 +2027,6 @@ "node": ">=12" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2548,12 +2476,6 @@ "node": ">= 0.4" } }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2584,18 +2506,6 @@ "node": "*" } }, - "node_modules/moment-timezone": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", - "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2789,34 +2699,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-big-calendar": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.20.0.tgz", - "integrity": "sha512-Lp1mvG34l/9xtb/2LsBb4UAF3iPcUkmDqbmpsvDHzp/n8GA5gtn3+nf8BsULWw08opKDgv38nQi75dQlOOqzkg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.7", - "clsx": "^2.1.1", - "date-arithmetic": "^4.1.0", - "dayjs": "^1.11.21", - "dom-helpers": "^6.0.1", - "globalize": "^1.7.1", - "invariant": "^2.2.4", - "lodash": "^4.18.1", - "lodash-es": "^4.18.1", - "luxon": "^3.7.2", - "memoize-one": "^6.0.0", - "moment": "^2.29.4", - "moment-timezone": "^0.5.48", - "prop-types": "^15.8.1", - "react-overlays": "^5.2.1", - "uncontrollable": "^7.2.1" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17 || ^18 || ^19", - "react-dom": "^16.14.0 || ^17 || ^18 || ^19" - } - }, "node_modules/react-color": { "version": "2.19.3", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", @@ -2885,42 +2767,6 @@ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, - "node_modules/react-overlays": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz", - "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.8", - "@popperjs/core": "^2.11.6", - "@restart/hooks": "^0.4.7", - "@types/warning": "^3.0.0", - "dom-helpers": "^5.2.0", - "prop-types": "^15.7.2", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": ">=16.3.0", - "react-dom": ">=16.3.0" - } - }, - "node_modules/react-overlays/node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -3281,21 +3127,6 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "license": "0BSD" }, - "node_modules/uncontrollable": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", - "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.6.3", - "@types/react": ">=16.9.11", - "invariant": "^2.2.4", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -3495,15 +3326,6 @@ } } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/client/package.json b/client/package.json index 8e4ae90..babae3e 100644 --- a/client/package.json +++ b/client/package.json @@ -20,7 +20,6 @@ "cron-parser": "^5.5.0", "moment": "^2.30.1", "react": "^19.2.1", - "react-big-calendar": "^1.19.4", "react-color": "2.19.3", "react-dom": "^19.2.1", "react-grid-layout": "^2.1.0", diff --git a/client/src/components/CalendarWidget.jsx b/client/src/components/CalendarWidget.jsx index 442c8b0..c40237d 100644 --- a/client/src/components/CalendarWidget.jsx +++ b/client/src/components/CalendarWidget.jsx @@ -1,7 +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 { Calendar, momentLocalizer } from 'react-big-calendar'; import moment from 'moment'; import { SketchPicker } from 'react-color'; import axios from 'axios'; @@ -10,12 +9,9 @@ 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 'react-big-calendar/lib/css/react-big-calendar.css'; import MonthDayCell from './MonthDayCell.jsx'; import ColorPickerPopover from './ColorPickerPopover.jsx'; -const localizer = momentLocalizer(moment); - const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const WEEKDAY_OPTIONS = [ { label: 'Sunday', value: 'sunday' }, From ef9654a3698ebcaba6ab7ca3584ccf6113483614 Mon Sep 17 00:00:00 2001 From: jherforth Date: Thu, 13 Aug 2026 17:00:17 -0400 Subject: [PATCH 2/8] i18n foundation: language switching, date seam, chore widget in Spanish (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First phase of multi-language support. Everything here is complete and working; the remaining widgets still render English and are listed below. Infrastructure - i18next + react-i18next + language detection, set up in client/src/i18n. English is bundled (it is the fallback); other languages and their date locales load on demand, so an English household downloads nothing extra. - Language is per display, stored in localStorage like theme and screensaver settings, so a kitchen tablet and a phone can differ. A display that has never chosen follows the `default_language` server setting if one is set. - Missing keys log a warning in development naming the namespace and key. Date seam (client/src/utils/dateUtils.js) - Every user-visible date now formats through one module, so changing language re-formats the whole UI from a single place, and replacing moment later (#120) touches this file rather than every call site. - Display helpers are separated from machine ones. toDateKey and friends deliberately stay unlocalized: those YYYY-MM-DD strings are map keys, API parameters, and event-matching identifiers, and localizing them would break calendar lookup silently. Explicit week-start setting - Chosen deliberately over deriving it from the language: households disagree with their locale often enough, and it reflows the whole calendar grid. Translated so far - Chore widget end to end: chores, bonus chores, prize store, split spending, quick spend, transfer, snooze, and the redemption celebration. - Admin Panel language and week-start controls. - Day-of-week checkboxes show translated labels while still storing the English key that crontab conversion and the API depend on. Tooling - npm run check:i18n fails if any English key is missing from another language, wired into CI for the frontend job. - docs/guides/translations.md covers adding a language, adding strings, and the traps (machine formats, stored values, user data, server errors). Not yet converted, still English in every language: the rest of the Admin Panel, and the Calendar, Weather, and Photos widgets. Each is a self-contained follow-up — add its namespace to NAMESPACES once its locale files exist. Verified: client 79/79, build clean, translation check 115/115 keys at 100%, and an 8/8 browser pass covering English default, switching to Spanish, no leaked keys, persistence across reload, the html lang attribute, and zero page errors. Spanish ships as separate 1.4-2.8 KB chunks per namespace. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci-tests.yml | 7 + client/package-lock.json | 76 ++++++++++ client/package.json | 4 + client/scripts/checkTranslations.js | 82 +++++++++++ client/src/components/AdminPanel.jsx | 77 ++++++++++ client/src/components/ChoreWidget.jsx | 161 +++++++++++---------- client/src/components/PrizeCelebration.jsx | 4 +- client/src/i18n/index.js | 117 +++++++++++++++ client/src/i18n/locales/en/admin.json | 14 ++ client/src/i18n/locales/en/chores.json | 89 ++++++++++++ client/src/i18n/locales/en/common.json | 56 +++++++ client/src/i18n/locales/es/admin.json | 14 ++ client/src/i18n/locales/es/chores.json | 89 ++++++++++++ client/src/i18n/locales/es/common.json | 56 +++++++ client/src/main.jsx | 27 +++- client/src/utils/dateUtils.js | 103 +++++++++++++ client/src/utils/interfaceSettings.js | 31 ++++ docs/guides/translations.md | 103 +++++++++++++ 18 files changed, 1033 insertions(+), 77 deletions(-) create mode 100644 client/scripts/checkTranslations.js create mode 100644 client/src/i18n/index.js create mode 100644 client/src/i18n/locales/en/admin.json create mode 100644 client/src/i18n/locales/en/chores.json create mode 100644 client/src/i18n/locales/en/common.json create mode 100644 client/src/i18n/locales/es/admin.json create mode 100644 client/src/i18n/locales/es/chores.json create mode 100644 client/src/i18n/locales/es/common.json create mode 100644 client/src/utils/dateUtils.js create mode 100644 docs/guides/translations.md diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index bd27048..c899fd3 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -40,3 +40,10 @@ jobs: - name: Run tests (${{ matrix.name }}) run: npm test working-directory: ${{ matrix.path }} + + # Every English key must exist in every other language (issue #137), so a + # new string can't ship as untranslated text in a translated UI. + - name: Check translation completeness + if: matrix.name == 'frontend' + run: npm run check:i18n + working-directory: ${{ matrix.path }} diff --git a/client/package-lock.json b/client/package-lock.json index b07a2ca..d2d29d5 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,11 +14,14 @@ "@mui/material": "^9.2.0", "axios": "^1.15.2", "cron-parser": "^5.5.0", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "moment": "^2.30.1", "react": "^19.2.1", "react-color": "2.19.3", "react-dom": "^19.2.1", "react-grid-layout": "^2.1.0", + "react-i18next": "^17.0.11", "react-rnd": "^10.5.2", "recharts": "^3.9.2" }, @@ -1979,6 +1982,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1992,6 +2004,43 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/immer": { "version": "11.1.9", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz", @@ -2761,6 +2810,33 @@ "react-dom": ">= 16.3.0" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", diff --git a/client/package.json b/client/package.json index babae3e..19765c7 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,7 @@ "build": "vite build", "preview": "vite preview", "test": "vitest run", + "check:i18n": "node scripts/checkTranslations.js", "test:watch": "vitest", "test:coverage": "npm run test:clean && vitest run --coverage", "test:clean": "node -e \"const fs=require('node:fs'); if (fs.existsSync('coverage')) fs.rmSync('coverage',{recursive:true,force:true});\"" @@ -18,11 +19,14 @@ "@mui/material": "^9.2.0", "axios": "^1.15.2", "cron-parser": "^5.5.0", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "moment": "^2.30.1", "react": "^19.2.1", "react-color": "2.19.3", "react-dom": "^19.2.1", "react-grid-layout": "^2.1.0", + "react-i18next": "^17.0.11", "react-rnd": "^10.5.2", "recharts": "^3.9.2" }, diff --git a/client/scripts/checkTranslations.js b/client/scripts/checkTranslations.js new file mode 100644 index 0000000..0edfe50 --- /dev/null +++ b/client/scripts/checkTranslations.js @@ -0,0 +1,82 @@ +// Completeness check for translations (issue #137). +// +// English is the source of truth: every key under locales/en must exist in +// every other language, with no extra keys that English does not have. Run in +// CI so a new English string can't silently ship as untranslated text in +// another language. +// +// node scripts/checkTranslations.js report and exit non-zero on gaps +// node scripts/checkTranslations.js --report report only, always exit 0 +const fs = require('node:fs'); +const path = require('node:path'); + +const localesDir = path.join(__dirname, '..', 'src', 'i18n', 'locales'); +const reportOnly = process.argv.includes('--report'); + +// Flatten to dotted paths so nesting differences show up as key differences. +const flatten = (obj, prefix = '') => { + const out = []; + for (const [key, value] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) out.push(...flatten(value, full)); + else out.push(full); + } + return out; +}; + +const readNamespace = (lang, ns) => { + const file = path.join(localesDir, lang, `${ns}.json`); + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, 'utf8')); +}; + +const languages = fs.readdirSync(localesDir).filter((d) => fs.statSync(path.join(localesDir, d)).isDirectory()); +if (!languages.includes('en')) { + console.error('No English locale found; nothing to compare against.'); + process.exit(1); +} +const namespaces = fs.readdirSync(path.join(localesDir, 'en')) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace('.json', '')); + +let problems = 0; +for (const lang of languages.filter((l) => l !== 'en')) { + const missingAll = []; + const extraAll = []; + for (const ns of namespaces) { + const english = flatten(readNamespace('en', ns)); + const translated = readNamespace(lang, ns); + if (translated === null) { + missingAll.push(`${ns}.json (whole file)`); + continue; + } + const theirs = new Set(flatten(translated)); + for (const key of english) if (!theirs.has(key)) missingAll.push(`${ns}:${key}`); + const mine = new Set(english); + for (const key of theirs) if (!mine.has(key)) extraAll.push(`${ns}:${key}`); + } + + const total = namespaces.reduce((sum, ns) => sum + flatten(readNamespace('en', ns)).length, 0); + const done = total - missingAll.length; + const pct = total === 0 ? 100 : Math.round((done / total) * 100); + console.log(`\n${lang}: ${done}/${total} keys (${pct}%)`); + + if (missingAll.length) { + problems += missingAll.length; + console.log(` missing (${missingAll.length}):`); + for (const key of missingAll.slice(0, 25)) console.log(` - ${key}`); + if (missingAll.length > 25) console.log(` ... and ${missingAll.length - 25} more`); + } + if (extraAll.length) { + problems += extraAll.length; + console.log(` not in English — stale or misspelled (${extraAll.length}):`); + for (const key of extraAll.slice(0, 25)) console.log(` - ${key}`); + } + if (!missingAll.length && !extraAll.length) console.log(' complete'); +} + +if (problems && !reportOnly) { + console.error(`\n${problems} translation problem(s). Add the missing keys, or run with --report to just see the gaps.`); + process.exit(1); +} +console.log('\nTranslation check passed.'); diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index 3b3d4cb..8f0b6f4 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -101,7 +101,13 @@ import { readLocalScreensaverSettings, readLocalAutoDarkModeSettings, readLocalVacationModeSettings, + readLocalWeekStart, + writeLocalWeekStart, + normalizeWeekStart, + WEEK_START_UPDATED_EVENT, } from '../utils/interfaceSettings.js'; +import { useTranslation } from 'react-i18next'; +import { changeLanguage, SUPPORTED_LANGUAGES } from '../i18n/index.js'; const USERS_UPDATED_EVENT = 'homeglow:users-updated'; const DEVICE_SETTINGS_UPDATED_EVENT = 'homeglow:device-settings-updated'; @@ -157,6 +163,7 @@ const buildTagUrl = (repository, tagName) => { const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => { + const { t, i18n } = useTranslation(['admin', 'common']); const isMobile = useIsMobile(); const [currentDeviceName, setCurrentDeviceName] = useState(() => getDeviceName()); const API_DEVICE_URL = getDeviceApiBase(API_BASE_URL); @@ -240,6 +247,29 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => { const [draggingTabNumber, setDraggingTabNumber] = useState(null); // User display order (issue #134): drag on desktop, arrows everywhere. const [draggingUserId, setDraggingUserId] = useState(null); + // Language & week start (issue #137), both per-display. + const [weekStart, setWeekStart] = useState(readLocalWeekStart); + + const handleLanguageChange = async (code) => { + try { + await changeLanguage(code); + setSaveMessage({ show: true, type: 'success', text: t('admin:language.saved') }); + } catch (error) { + console.error('Error switching language:', error); + setSaveMessage({ show: true, type: 'error', text: t('admin:language.switchFailed') }); + } + setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000); + }; + + const handleWeekStartChange = (value) => { + const normalized = normalizeWeekStart(value); + setWeekStart(normalized); + writeLocalWeekStart(normalized); + // The calendar reads this on mount, so tell any open widget to re-read it. + window.dispatchEvent(new Event(WEEK_START_UPDATED_EVENT)); + setSaveMessage({ show: true, type: 'success', text: t('admin:weekStart.saved') }); + setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000); + }; const [devices, setDevices] = useState([]); const [copyDeviceDialog, setCopyDeviceDialog] = useState({ open: false, device: null }); const [deleteDeviceDialog, setDeleteDeviceDialog] = useState({ open: false, device: null }); @@ -2642,6 +2672,53 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => { {activeTab === 1 && ( + {/* Language & region (issue #137). Language is per-display, like + the other interface settings; week start is explicit rather + than derived from the language. */} + + + + + {t('common:language.label')} + + + {t('common:language.helper')} + + + + + + {t('common:weekStart.label')} + + + {t('common:weekStart.helper')} + + + + + + Accent Colors @@ -973,7 +977,7 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { variant="contained" size="small" > - Add Chore + {t('chores:widget.addChore')} @@ -1016,7 +1020,7 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { {allRegularChoresCompleted && ( { {userChores.length === 0 ? ( - No chores for today + {t('chores:widget.noChoresToday')} ) : ( userChores.map(schedule => renderChoreItem(schedule)) @@ -1053,16 +1057,16 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { }} > - 🥟 Bonus Chores + {t('chores:bonus.heading')} - Available: + {t('chores:bonus.available')} {availableBonusChores.length === 0 ? ( - No bonus chores available + {t('chores:bonus.noneAvailable')} ) : ( availableBonusChores.map(schedule => ( @@ -1122,19 +1126,19 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { > - 🛍️ Prize Store + {t('chores:prizeStore.title')} {prizeOffers.length === 0 ? ( - The store is empty right now. Parents can stock it from Settings → Prize Management. + {t('chores:prizeStore.empty')} ) : ( {prizeOffers.filter((offer) => offer.status === 'requested').length > 0 && ( - Waiting for a parent + {t('chores:prizeStore.waitingForParent')} )} {prizeOffers.filter((offer) => offer.status === 'requested').map((offer) => ( @@ -1152,24 +1156,26 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { - Requested by {offer.requested_by_name} + {/* Composed from fragments rather than one interpolated + string so the names can stay bold. */} + {t('chores:prizeStore.requestedByLabel')} {offer.requested_by_name} {(offer.split_user_ids || []).length > 0 && (() => { const coNames = offer.split_user_ids .map((id) => users.find((u) => u.id === id)?.username) .filter(Boolean); const share = Math.floor(offer.clam_cost / (offer.split_user_ids.length + 1)); - return <> — splitting with {coNames.join(', ')} ({share} 🥟 each); + return <> {t('chores:prizeStore.splittingWithLabel')} {coNames.join(', ')} {t('chores:prizeStore.eachShareSuffix', { share })}; })()} @@ -1177,7 +1183,7 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { {prizeOffers.filter((offer) => offer.status === 'available').length > 0 && ( - On the shelf + {t('chores:prizeStore.onTheShelf')} )} {prizeOffers.filter((offer) => offer.status === 'available').map((offer) => ( @@ -1203,7 +1209,7 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { return ( <> - Splitting between: + {t('chores:prizeStore.splittingBetween')} {users.map((user) => { const selected = splitDraft.userIds.includes(user.id); return ( @@ -1226,20 +1232,24 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { disabled={count < 2} onClick={() => requestPrizeOffer(offer.id, splitDraft.userIds[0], splitDraft.userIds.slice(1))} > - Request split + {t('chores:prizeStore.requestSplit')} - {count < 2 ? 'Pick at least 2 kids' : `${share} 🥟 each${discounted > 0 ? ` (${discounted} 🥟 discounted)` : ''}`} + {count < 2 + ? t('chores:prizeStore.pickTwoKids') + : (discounted > 0 + ? t('chores:prizeStore.eachShareDiscounted', { share, discounted }) + : t('chores:prizeStore.eachShare', { share }))} ); })() : ( - Request for: + {t('chores:prizeStore.requestFor')} {users.map((user) => ( { ))} {users.length > 1 && ( )} @@ -1264,19 +1274,18 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { setQuickSpend({ open: false, user: null, amount: '', note: '' })} maxWidth="xs" fullWidth> - 🥟 Redeem clams — {quickSpend.user?.username} + {t('chores:quickSpend.title', { name: quickSpend.user?.username || '' })} - Balance: {quickSpend.user?.clam_total || 0} clams. Record clams spent - outside the store (e.g. a toy bought while out). + {t('chores:quickSpend.balanceLabel')} {t('chores:quickSpend.clamCount', { count: quickSpend.user?.clam_total || 0 })}{t('chores:quickSpend.balanceHint')} {(() => { @@ -1319,7 +1328,7 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { onClick={() => setQuickSpend((prev) => ({ ...prev, amount: '' }))} sx={{ height: 52, fontWeight: 'bold', borderColor: 'var(--accent)', color: 'var(--accent)' }} > - Clear + {t('common:actions.clear')} { setQuickSpend((prev) => ({ ...prev, note: e.target.value }))} /> - + @@ -1394,29 +1403,29 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { } }} > - Add New Chore + {t('chores:add.dialogTitle')} setNewChore({ ...newChore, title: e.target.value })} sx={{ mb: 2, mt: 1 }} /> setNewChore({ ...newChore, description: e.target.value })} sx={{ mb: 2 }} /> - Assign to User + {t('chores:add.assignToUser')} setDraftTempUnit(e.target.value)} > - Fahrenheit (°F) - Celsius (°C) + {t('weather:settings.fahrenheit')} + {t('weather:settings.celsius')} - Layout Mode + {t('weather:settings.layoutMode')} - - + + ); @@ -1179,7 +1189,7 @@ const WeatherWidget = ({ ); @@ -1194,7 +1204,7 @@ const WeatherWidget = ({ p: 2 }}> 🌤️ Weather - No weather data available + {t('weather:widget.noData')} ); } else { @@ -1227,7 +1237,7 @@ const WeatherWidget = ({ Date: Thu, 13 Aug 2026 17:13:50 -0400 Subject: [PATCH 4/8] Translate the photo widget (#137) Moves the photo widget's strings into a photos namespace: the settings popover (sources, per-view count, size, transition, speed), the source dialog for both Immich and HomeGlow Photos, the empty states, and the confirm/error messages. The size, speed, and transition options keep their stored values (880, 3000, "fade") and translate only the visible label, so existing device settings keep working untouched. Verified: client 79/79, build clean, translation check 211/211 keys at 100%. Co-Authored-By: Claude Fable 5 --- client/src/components/PhotoWidget.jsx | 86 +++++++++++++------------- client/src/i18n/index.js | 2 +- client/src/i18n/locales/en/photos.json | 57 +++++++++++++++++ client/src/i18n/locales/es/photos.json | 57 +++++++++++++++++ 4 files changed, 158 insertions(+), 44 deletions(-) create mode 100644 client/src/i18n/locales/en/photos.json create mode 100644 client/src/i18n/locales/es/photos.json diff --git a/client/src/components/PhotoWidget.jsx b/client/src/components/PhotoWidget.jsx index fbeeafb..50f4777 100644 --- a/client/src/components/PhotoWidget.jsx +++ b/client/src/components/PhotoWidget.jsx @@ -2,9 +2,11 @@ import React, { useState, useEffect, useRef } from 'react'; import { Typography, Box, IconButton, Popover, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Switch, FormControlLabel, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, ListItemSecondaryAction, CircularProgress, Alert, Chip } from '@mui/material'; import { Settings, Add, Delete, Edit, Refresh, ChevronLeft, ChevronRight, PlayArrow, Pause, CloudUpload } from '@mui/icons-material'; import axios from 'axios'; +import { useTranslation } from 'react-i18next'; import { API_BASE_URL } from '../utils/apiConfig.js'; const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { + const { t } = useTranslation(['photos', 'common']); const [photos, setPhotos] = useState([]); const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [loading, setLoading] = useState(true); @@ -164,7 +166,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { }; const handleDeleteSource = async (sourceId) => { - if (window.confirm('Are you sure you want to delete this photo source?')) { + if (window.confirm(t('photos:confirm.deleteSource'))) { try { await axios.delete(`${API_BASE_URL}/api/photo-sources/${sourceId}`); await fetchPhotoSources(); @@ -187,7 +189,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { setTestingConnection(false); } } else { - setTestResult({ success: false, message: 'Please save the photo source before testing' }); + setTestResult({ success: false, message: t('photos:errors.saveBeforeTesting') }); } }; @@ -204,7 +206,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { setShowSourceDialog(false); } catch (error) { console.error('Error saving photo source:', error); - alert('Failed to save photo source. Please try again.'); + alert(t('photos:errors.saveFailed')); } finally { setSavingSource(false); } @@ -276,7 +278,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { {!loading && !error && photos.length === 0 && ( - No photos available. Add a photo source in settings. + {t('photos:widget.noPhotos')} )} @@ -322,7 +324,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { Photo { > - Photo Sources + {t('photos:settings.sources')} - Photos Per View + {t('photos:settings.photosPerView')} { savePreference('PHOTO_WIDGET_PHOTO_SIZE', value); }} > - Auto (fit widget) - Extra Large (880px) - Large (720px) - Medium (580px) - Small (450px) - Extra Small (300px) + {t('photos:size.auto')} + {t('photos:size.extraLarge')} + {t('photos:size.large')} + {t('photos:size.medium')} + {t('photos:size.small')} + {t('photos:size.extraSmall')} - Transition Effect + {t('photos:settings.transitionEffect')} - Slideshow Speed + {t('photos:settings.slideshowSpeed')} @@ -543,7 +545,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { ))} {photoSources.length === 0 && ( - No photo sources configured + {t('photos:settings.noSources')} )} @@ -572,21 +574,21 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { setSourceForm({ ...sourceForm, name: e.target.value })} margin="normal" /> - Type + {t('photos:source.type')} @@ -594,29 +596,29 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { <> setSourceForm({ ...sourceForm, url: e.target.value })} margin="normal" placeholder="https://immich.example.com" - helperText="Your Immich server URL" + helperText={t('photos:source.immichUrlPlaceholder')} /> setSourceForm({ ...sourceForm, api_key: e.target.value })} margin="normal" - helperText="Get from Immich Settings → API Keys" + helperText={t('photos:source.apiKeyHelp')} /> setSourceForm({ ...sourceForm, album_id: e.target.value })} margin="normal" - helperText="Leave empty to show all photos" + helperText={t('photos:source.albumIdHelp')} /> )} @@ -625,13 +627,12 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { {!editingSource ? ( - Save this source first, then open the upload page to add photos from your device. + {t('photos:source.saveFirst')} ) : ( <> - Upload photos directly from your device. They will be stored on your HomeGlow - server and displayed in this widget. + {t('photos:source.uploadExplain')} - The upload page is mobile-friendly—open it on your phone to upload photos - straight from your camera roll. + {t('photos:source.mobileHint')} )} @@ -666,10 +666,10 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { disabled={testingConnection} startIcon={testingConnection ? : } > - Test Connection + {t('photos:source.testConnection')} )} - + ) : } - Chore definitions hold the title, description, and clam value. Add schedules below to assign them to users with a recurrence pattern. + {t('chores:schedules.definitionsHelp')} @@ -546,7 +544,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {chores.length === 0 ? ( - No chores defined yet. + {t('chores:schedules.noChores')} ) : ( @@ -557,7 +555,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { - {c.description || No description} + {c.description || {t('chores:schedules.noDescription')}} @@ -574,12 +572,12 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { - + openEditChore(c)}> - + setDeleteChoreDialog({ open: true, chore: c })}> @@ -597,35 +595,35 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { Schedules - Schedules link chores to users and specify when they recur. A chore can have multiple schedules for different users or frequencies. + {t('chores:schedules.schedulesHelp')} - Filter by User - setFilterUser(e.target.value)}> + {t('chores:schedules.allUsers')} + {t('chores:schedules.unassignedBonus')} {users.map(u => {u.username})} - Filter by Chore - setFilterChore(e.target.value)}> + {t('chores:schedules.allChores')} {chores.map(c => {c.title})} {(filterUser || filterChore) && ( )} @@ -651,7 +649,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {filteredSchedules.length === 0 ? ( - No schedules found. + {t('chores:schedules.noSchedules')} ) : ( @@ -683,11 +681,11 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {s.crontab && s.duration === 'until-completed' ? ( - + ) : s.crontab && s.duration === 'once-completed' ? ( ) : s.crontab ? ( - + ) : ( )} @@ -767,7 +765,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { /> setChoreForm(f => ({ ...f, clam_value: parseInt(e.target.value) || 0 }))} @@ -794,7 +792,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { - Delete Chore + {t('chores:schedules.deleteChore')} @@ -858,7 +856,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { label="Assigned To" onChange={(e) => updateScheduleForm({ user_id: e.target.value })} > - Unassigned (Bonus chore) + {t('chores:schedules.unassignedBonusChore')} {users.map(u => {u.username})} @@ -872,7 +870,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { onChange={(e) => updateScheduleForm({ isOneTime: e.target.checked })} /> } - label="One-time task (no recurrence)" + label={t('chores:schedules.oneTimeTask')} /> {!scheduleForm.isOneTime && ( @@ -894,8 +892,8 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { label="Duration" onChange={(e) => updateScheduleForm({ duration: e.target.value })} > - Day Of - Until Completed + {t('chores:schedules.dayOf')} + {t('chores:schedules.untilCompleted')} Once Completed @@ -913,7 +911,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { { const digitsOnly = e.target.value.replace(/\D/g, ''); @@ -926,10 +924,10 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { - Sleep Unit + {t('chores:schedules.sleepUnit')} updateScheduleForm({ selectedPreset: e.target.value })} > {CRONTAB_PRESETS.map(p => ( - + - {p.label} + {t(`chores:presets.${p.key}`)} {p.value} @@ -967,10 +965,10 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {scheduleForm.scheduleMode === 'days' && ( - Select which days this chore should appear: + {t('chores:schedules.selectDays')} - {DAY_OPTIONS.map(day => ( + {getDayOptions().map(day => ( updateScheduleForm({ customCrontab: e.target.value })} placeholder="0 0 * * 1" @@ -1028,7 +1026,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { )} {scheduleForm.isOneTime && ( - This chore will appear once and hide itself after completion. + {t('chores:schedules.appearsOnce')} )} @@ -1038,7 +1036,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {scheduleForm.isOneTime ? ( ) : ( } - label="Play sound when due" + label={t('chores:schedules.playSoundWhenDue')} /> {scheduleForm.sound_enabled && scheduleForm.due_time && ( @@ -1114,12 +1112,12 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { allowDelete /> updateScheduleForm({ reminder_interval_minutes: e.target.value })} - helperText="Leave blank to ring once. Reminders repeat until the chore is completed." + helperText={t('chores:schedules.repeatReminderHelp')} inputProps={{ min: 0 }} sx={{ maxWidth: 280 }} /> @@ -1136,10 +1134,10 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { onChange={(e) => updateScheduleForm({ transferable: e.target.checked })} /> } - label="Transferable" + label={t('chores:schedules.transferable')} /> - Allows reassignment from the dashboard. + {t('chores:schedules.transferableHelp')} updateScheduleForm({ can_snooze: e.target.checked })} /> } - label="Can snooze/defer" + label={t('chores:schedules.canSnooze')} /> - Allows the scheduled time to be deferred by a set time. + {t('chores:schedules.canSnoozeHelp')} @@ -1164,7 +1162,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { onChange={(e) => updateScheduleForm({ visible: e.target.checked })} /> } - label="Visible (active)" + label={t('chores:schedules.visibleActive')} /> @@ -1183,10 +1181,10 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {/* ── DELETE SCHEDULE DIALOG ────────────────────────── */} setDeleteScheduleDialog({ open: false, schedule: null })} maxWidth="xs" fullWidth fullScreen={isMobile}> - Delete Schedule + {t('chores:schedules.deleteSchedule')} - This will permanently delete this schedule. Completion history is preserved. + {t('chores:schedules.deleteScheduleWarning')} Delete schedule for {deleteScheduleDialog.schedule?.title} assigned to{' '} diff --git a/client/src/i18n/locales/en/chores.json b/client/src/i18n/locales/en/chores.json index 134eeda..8b1255b 100644 --- a/client/src/i18n/locales/en/chores.json +++ b/client/src/i18n/locales/en/chores.json @@ -85,5 +85,87 @@ }, "celebration": { "redeemedAPrize": "{{name}} redeemed a prize!" + }, + "schedules": { + "definitionsHeading": "Chore Definitions", + "newChore": "New Chore", + "definitionsHelp": "Chore definitions hold the title, description, and clam value. Add schedules below to assign them to users with a recurrence pattern.", + "noChores": "No chores defined yet.", + "noDescription": "No description", + "clams": "Clams", + "schedulesColumn": "Schedules", + "editChore": "Edit chore", + "deleteChoreAndSchedules": "Delete chore and all its schedules", + "newSchedule": "New Schedule", + "schedulesHelp": "Schedules link chores to users and specify when they recur. A chore can have multiple schedules for different users or frequencies.", + "filterByUser": "Filter by User", + "allUsers": "All Users", + "unassignedBonus": "Unassigned (Bonus)", + "filterByChore": "Filter by Chore", + "allChores": "All Chores", + "clearFilters": "Clear Filters", + "noSchedules": "No schedules found.", + "chore": "Chore", + "assignedTo": "Assigned To", + "nextOccurrence": "Next Occurrence", + "duration": "Duration", + "untilCompleted": "Until Completed", + "onceCompleted": "Once Completed", + "dayOf": "Day Of", + "visible": "Visible", + "duplicate": "Duplicate", + "unknownUser": "User #{{id}}", + "clamValue": "Clam Value", + "deleteChore": "Delete Chore", + "unassignedBonusChore": "Unassigned (Bonus chore)", + "oneTimeTask": "One-time task (no recurrence)", + "modePreset": "Preset", + "modeDaysOfWeek": "Days of Week", + "modeCustomCrontab": "Custom Crontab", + "sleepCount": "Sleep Count", + "sleepUnit": "Sleep Unit", + "unitDays": "Days", + "unitWeeks": "Weeks", + "unitMonths": "Months", + "unitYears": "Years", + "schedulePreset": "Schedule Preset", + "selectDays": "Select which days this chore should appear:", + "selectAtLeastOneDay": "Select at least one day, or use the \"One-time task\" toggle above.", + "crontabExpression": "Crontab Expression", + "nextOccurrenceIs": "Next occurrence: {{when}}", + "appearsOnce": "This chore will appear once and hide itself after completion.", + "dueDateOptional": "Due date (optional)", + "dueDateHelp": "Calendar deadline. The chore turns yellow when due, red when overdue.", + "daysUntilDue": "Days until chore is due", + "daysUntilDueHelp": "Leave blank for no due date. This sets a relative due-date offset for recurring chores.", + "dueTimeOptional": "Due time (optional)", + "dueTimeHelp": "Time of day this chore is due. Required to play a sound.", + "playSoundWhenDue": "Play sound when due", + "sound": "Sound", + "repeatReminder": "Repeat reminder every (minutes)", + "repeatReminderHelp": "Leave blank to ring once. Reminders repeat until the chore is completed.", + "transferable": "Transferable", + "transferableHelp": "Allows reassignment from the dashboard.", + "canSnooze": "Can snooze/defer", + "canSnoozeHelp": "Allows the scheduled time to be deferred by a set time.", + "visibleActive": "Visible (active)", + "deleteSchedule": "Delete Schedule", + "deleteScheduleWarning": "This will permanently delete this schedule. Completion history is preserved.", + "deleteScheduleFor": "Delete schedule for" + }, + "presets": { + "daily": "Daily", + "everyOtherDay": "Every Other Day", + "weekdays": "Weekdays (Mon–Fri)", + "weekends": "Weekends (Sat–Sun)" + }, + "daysShort": { + "sun": "Sun", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat" } } diff --git a/client/src/i18n/locales/es/chores.json b/client/src/i18n/locales/es/chores.json index 5ca5c8b..fa84ce6 100644 --- a/client/src/i18n/locales/es/chores.json +++ b/client/src/i18n/locales/es/chores.json @@ -85,5 +85,87 @@ }, "celebration": { "redeemedAPrize": "¡{{name}} canjeó un premio!" + }, + "schedules": { + "definitionsHeading": "Definiciones de tareas", + "newChore": "Nueva tarea", + "definitionsHelp": "Las definiciones de tareas contienen el título, la descripción y el valor en almejas. Añade programaciones abajo para asignarlas a personas con un patrón de repetición.", + "noChores": "Aún no hay tareas definidas.", + "noDescription": "Sin descripción", + "clams": "Almejas", + "schedulesColumn": "Programaciones", + "editChore": "Editar tarea", + "deleteChoreAndSchedules": "Eliminar la tarea y todas sus programaciones", + "newSchedule": "Nueva programación", + "schedulesHelp": "Las programaciones vinculan tareas con personas e indican cuándo se repiten. Una tarea puede tener varias programaciones para distintas personas o frecuencias.", + "filterByUser": "Filtrar por persona", + "allUsers": "Todas las personas", + "unassignedBonus": "Sin asignar (extra)", + "filterByChore": "Filtrar por tarea", + "allChores": "Todas las tareas", + "clearFilters": "Borrar filtros", + "noSchedules": "No se encontraron programaciones.", + "chore": "Tarea", + "assignedTo": "Asignada a", + "nextOccurrence": "Próxima vez", + "duration": "Duración", + "untilCompleted": "Hasta completarla", + "onceCompleted": "Una vez completada", + "dayOf": "El mismo día", + "visible": "Visible", + "duplicate": "Duplicar", + "unknownUser": "Persona n.º {{id}}", + "clamValue": "Valor en almejas", + "deleteChore": "Eliminar tarea", + "unassignedBonusChore": "Sin asignar (tarea extra)", + "oneTimeTask": "Tarea única (sin repetición)", + "modePreset": "Predefinido", + "modeDaysOfWeek": "Días de la semana", + "modeCustomCrontab": "Crontab personalizado", + "sleepCount": "Cantidad de espera", + "sleepUnit": "Unidad de espera", + "unitDays": "Días", + "unitWeeks": "Semanas", + "unitMonths": "Meses", + "unitYears": "Años", + "schedulePreset": "Programación predefinida", + "selectDays": "Elige los días en los que debe aparecer esta tarea:", + "selectAtLeastOneDay": "Elige al menos un día o usa el interruptor \"Tarea única\" de arriba.", + "crontabExpression": "Expresión crontab", + "nextOccurrenceIs": "Próxima vez: {{when}}", + "appearsOnce": "Esta tarea aparecerá una vez y se ocultará al completarse.", + "dueDateOptional": "Fecha límite (opcional)", + "dueDateHelp": "Fecha límite del calendario. La tarea se pone amarilla cuando vence y roja cuando está atrasada.", + "daysUntilDue": "Días hasta el vencimiento", + "daysUntilDueHelp": "Déjalo en blanco para no poner fecha límite. Esto fija un desfase relativo para las tareas periódicas.", + "dueTimeOptional": "Hora límite (opcional)", + "dueTimeHelp": "Hora del día en que vence la tarea. Necesaria para reproducir un sonido.", + "playSoundWhenDue": "Reproducir un sonido al vencer", + "sound": "Sonido", + "repeatReminder": "Repetir el aviso cada (minutos)", + "repeatReminderHelp": "Déjalo en blanco para que suene una sola vez. Los avisos se repiten hasta completar la tarea.", + "transferable": "Transferible", + "transferableHelp": "Permite reasignarla desde el panel.", + "canSnooze": "Se puede posponer", + "canSnoozeHelp": "Permite aplazar la hora programada durante un tiempo.", + "visibleActive": "Visible (activa)", + "deleteSchedule": "Eliminar programación", + "deleteScheduleWarning": "Esto eliminará esta programación de forma permanente. El historial de finalización se conserva.", + "deleteScheduleFor": "Eliminar la programación de" + }, + "presets": { + "daily": "A diario", + "everyOtherDay": "Cada dos días", + "weekdays": "Entre semana (lun-vie)", + "weekends": "Fines de semana (sáb-dom)" + }, + "daysShort": { + "sun": "dom", + "mon": "lun", + "tue": "mar", + "wed": "mié", + "thu": "jue", + "fri": "vie", + "sat": "sáb" } } From 35d39fbc5d016dc67c4c5d8cd77b0890095a2a2e Mon Sep 17 00:00:00 2001 From: jherforth Date: Thu, 13 Aug 2026 21:08:25 -0400 Subject: [PATCH 7/8] Finish the chore schedules tab (#137) Covers the last of the file: table headers (including the data-label attributes CSS renders as headings on mobile), the dialog buttons, the recurrence-mode radios and interval units, and the two composed strings for 'Once Completed (interval)' and 'Next occurrence: when', which now interpolate instead of concatenating. The delete-chore dialog's warning takes its schedule count as an interpolated value, and its prompt is composed so the chore's own title stays bold and untranslated. ChoreSchedulesTab now reports zero remaining hardcoded strings. Verified: client 79/79, build clean, translation check 359/359 keys at 100%. Co-Authored-By: Claude Fable 5 --- client/src/components/ChoreSchedulesTab.jsx | 117 ++++++++++---------- client/src/i18n/locales/en/chores.json | 6 +- client/src/i18n/locales/en/common.json | 3 +- client/src/i18n/locales/es/chores.json | 6 +- client/src/i18n/locales/es/common.json | 3 +- 5 files changed, 75 insertions(+), 60 deletions(-) diff --git a/client/src/components/ChoreSchedulesTab.jsx b/client/src/components/ChoreSchedulesTab.jsx index 40144d2..749ab14 100644 --- a/client/src/components/ChoreSchedulesTab.jsx +++ b/client/src/components/ChoreSchedulesTab.jsx @@ -517,7 +517,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {t('chores:schedules.definitionsHeading')}