i18n: extract things, history, projects, labels, filters, timer and points

Part of #145.

Fourteen files across seven feature areas that had no namespace yet: the
things create/edit modals and their history, the chore history detail and
edit modals, the activity feed, the points view and its redemption modal,
the project view with its selector and icon picker, the label view, the
advanced filter builder and the timer edit modal.

Seven new namespaces registered in `src/i18n/config.js` in one change
rather than one per PR, so the `ns:` array is touched once and my other
extraction PRs cannot conflict with this one. Namespaces stay
feature-scoped as described in #145; if you'd rather fold any of these
into `common` or `chores`, say which and I'll rework it.

Dictionaries: `history` 57 keys, `points` 50, `timer` 25, `projects` 16,
`things` 14, `filters` 13, `labels` 5.

English only — no translations, no behaviour change. Every t() value is
checked against this branch's base: the string must appear
character-for-character in the code it replaces (226 call sites).

Three values in `UserPoints` are matched loosely and worth naming. The
base builds the leaderboard heading and subtitle around a ternary —
`{mode === 'points' ? 'Points' : 'Tasks'} Leaderboard` and `Rankings based
on {…} during the selected time period` — so neither full sentence exists
contiguously in the source. Each key holds exactly what one branch
renders. The sentences are kept whole rather than split around the
ternary, since a sentence assembled from fragments cannot be reordered by
a translator.
This commit is contained in:
everysingletear
2026-08-13 10:21:57 +08:00
parent 7eba12e718
commit 72aa57f018
23 changed files with 579 additions and 258 deletions

View File

@@ -11,6 +11,7 @@ import FilterBuilderContent, {
import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import { useFilters } from '../../Filters/FilterQueries'
import { useTranslation } from 'react-i18next'
const EMPTY_FILTERS = []
@@ -25,6 +26,7 @@ const AdvancedFilterBuilder = ({
userProfile = null,
editingFilter = null,
}) => {
const { t } = useTranslation('filters')
const [filterName, setFilterName] = useState('')
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [selections, setSelections] = useState(defaultSelections())
@@ -76,15 +78,15 @@ const AdvancedFilterBuilder = ({
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
setError(t('builder.errorName'))
return
}
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
setError('A filter with this name already exists')
setError(t('nameExists'))
return
}
if (conditions.length === 0) {
setError('Please configure at least one filter condition')
setError(t('builder.errorConditions'))
return
}
onSave({
@@ -106,7 +108,7 @@ const AdvancedFilterBuilder = ({
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{editingFilter ? 'Edit Filter' : 'New Filter'}
{editingFilter ? t('builder.editTitle') : t('builder.newTitle')}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition
@@ -129,17 +131,17 @@ const AdvancedFilterBuilder = ({
{conditions.length > 0 ? (
<>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} task{previewCount !== 1 ? 's' : ''}
{t('tasks', { count: previewCount })}
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
{t('overdue', { count: previewOverdueCount })}
</Chip>
)}
</>
) : (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Add conditions to preview
{t('builder.addConditions')}
</Typography>
)}
</Box>
@@ -147,7 +149,7 @@ const AdvancedFilterBuilder = ({
{/* Actions */}
<ModalActions>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
{t('common:cancel')}
</Button>
<Button
variant='solid'
@@ -155,7 +157,7 @@ const AdvancedFilterBuilder = ({
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
{t('builder.save')}
</Button>
</ModalActions>
</Box>
@@ -168,10 +170,10 @@ const AdvancedFilterBuilder = ({
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Filter Name
{t('builder.name')}
</Typography>
<Input
placeholder='e.g. Overdue tasks for Alice'
placeholder={t('builder.namePlaceholder')}
value={filterName}
onChange={e => {
setFilterName(e.target.value)
@@ -193,13 +195,13 @@ const AdvancedFilterBuilder = ({
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Color
{t('builder.color')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{FILTER_COLORS.map(c => (
<Box
key={c.value}
title={c.name}
title={t(`common:colors.${c.key}`)}
onClick={() => setFilterColor(c.value)}
sx={{
width: 26,

View File

@@ -10,8 +10,10 @@ import {
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
const { t } = useTranslation('things')
const { ResponsiveModal } = useResponsiveModal()
const [name, setName] = useState(currentThing?.name || '')
@@ -33,17 +35,17 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
const isValid = () => {
const newErrors = {}
if (!name || name.trim() === '') {
newErrors.name = 'Name is required'
newErrors.name = t('errName')
}
if (type === 'number' && isNaN(state)) {
newErrors.state = 'State must be a number'
newErrors.state = t('errStateNumber')
}
if (type === 'boolean' && !['true', 'false'].includes(state)) {
newErrors.state = 'State must be true or false'
newErrors.state = t('errStateBool')
}
if ((type === 'text' && !state) || state.trim() === '') {
newErrors.state = 'State is required'
newErrors.state = t('errStateRequired')
}
setErrors(newErrors)
@@ -67,7 +69,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('common:cancel'),
onClick: onClose,
}}
primary={{
@@ -78,9 +80,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
}
>
<FormControl>
<Typography>Name</Typography>
<Typography>{t('name')}</Typography>
<Textarea
placeholder='Thing name'
placeholder={t('namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
sx={{ minWidth: 300 }}
@@ -101,9 +103,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
</FormControl>
{type === 'text' && (
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
value={state || ''}
onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }}
@@ -113,9 +115,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
)}
{type === 'number' && (
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
type='number'
value={state || ''}
onChange={e => {

View File

@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
const { t } = useTranslation('things')
const { ResponsiveModal } = useResponsiveModal()
const [state, setState] = useState(currentThing?.state || '')
@@ -13,7 +15,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
const newErrors = {}
if (state.trim() === '') {
newErrors.state = 'State is required'
newErrors.state = t('errStateRequired')
}
setErrors(newErrors)
@@ -38,18 +40,18 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
open={isOpen}
onClose={onClose}
size='sm'
title='Update state'
title={t('updateState')}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Update', onClick: handleSave }}
secondary={{ label: t('common:cancel'), onClick: onClose }}
primary={{ label: t('update'), onClick: handleSave }}
/>
}
>
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
value={state || ''}
onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }}

View File

@@ -3,6 +3,7 @@ import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
import PROJECT_ICONS from '../../../utils/ProjectIcons'
import { useTranslation } from 'react-i18next'
const IconPickerModal = ({
isOpen,
@@ -11,6 +12,7 @@ const IconPickerModal = ({
currentIcon,
projectColor,
}) => {
const { t } = useTranslation('projects')
const { ResponsiveModal } = useResponsiveModal()
const handleIconClick = iconValue => {
@@ -25,13 +27,13 @@ const IconPickerModal = ({
size='lg'
fullWidth={true}
unmountDelay={250}
title='Choose Project Icon'
title={t('iconPicker.chooseIcon')}
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
<ModalActions secondary={{ label: t('common:cancel'), onClick: onClose }} />
}
>
<FormControl>
<FormLabel>Available Icons</FormLabel>
<FormLabel>{t('iconPicker.availableIcons')}</FormLabel>
<Grid
container
spacing={1}
@@ -85,7 +87,7 @@ const IconPickerModal = ({
lineHeight: 1.2,
}}
>
{iconData.name}
{t(`icons.${iconData.key}`)}
</Typography>
</Box>
</Grid>

View File

@@ -12,6 +12,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocalization } from '../../../contexts/LocalizationContext'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
@@ -24,6 +25,7 @@ import {
import ConfirmationModal from './ConfirmationModal'
const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const { t } = useTranslation('timer')
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
@@ -181,8 +183,8 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{
onSuccess: () => {
showSuccess({
title: 'Session updated',
message: 'Timer session has been updated successfully.',
title: t('toast.sessionUpdatedTitle'),
message: t('toast.sessionUpdatedMessage'),
})
refetchTimer()
cancelEditingSession(sessionId)
@@ -190,15 +192,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
},
onError: () => {
showError({
title: 'Failed to update session',
message: 'Please try again.',
title: t('toast.sessionUpdateFailTitle'),
message: t('toast.tryAgain'),
})
},
},
)
} catch (error) {
showError({
title: 'Error updating session',
title: t('toast.sessionUpdateErrorTitle'),
message: error.message,
})
} finally {
@@ -213,15 +215,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{
onSuccess: () => {
showSuccess({
title: 'Session deleted',
message: 'Timer session has been deleted successfully.',
title: t('toast.sessionDeletedTitle'),
message: t('toast.sessionDeletedMessage'),
})
refetchTimer()
onTimerUpdate?.()
},
onError: error => {
showError({
title: 'Error deleting session',
title: t('toast.sessionDeleteErrorTitle'),
message: error.message,
})
},
@@ -235,10 +237,10 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const confirmDeleteSession = sessionId => {
setConfirmDeleteConfig({
isOpen: true,
title: 'Delete Timer Session',
message: 'Are you sure you want to delete this timer session?',
confirmText: 'Delete',
cancelText: 'Cancel',
title: t('toast.deleteConfirmTitle'),
message: t('toast.deleteConfirmMessage'),
confirmText: t('common:delete'),
cancelText: t('common:cancel'),
color: 'danger',
onClose: isConfirmed => {
if (isConfirmed) {
@@ -310,23 +312,23 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
tertiary={
!loading && timerData && !editingSessions[timerData.id]
? {
label: 'Delete',
label: t('common:delete'),
color: 'danger',
onClick: () => confirmDeleteSession(timerData.id),
}
: undefined
}
secondary={{ label: 'Close', onClick: handleClose }}
secondary={{ label: t('common:close'), onClick: handleClose }}
primary={
!loading && timerData
? editingSessions[timerData.id]
? {
label: 'Save',
label: t('common:save'),
onClick: () => saveSession(timerData.id),
loading,
}
: {
label: 'Edit',
label: t('common:edit'),
startDecorator: <Edit />,
onClick: () => startEditingSession(),
}
@@ -337,13 +339,13 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
>
{loading && (
<Alert color='neutral' sx={{ mb: 2 }}>
Loading timer data...
{t('loading')}
</Alert>
)}
{!loading && !timerData && (
<Alert color='warning' sx={{ mb: 2 }}>
No timer data found for this chore.
{t('noData')}
</Alert>
)}
@@ -403,7 +405,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Active Work
{t('activeWork')}
</Typography>
</Box>
<Box>
@@ -457,7 +459,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Break Time
{t('breakTime')}
</Typography>
</Box>
<Box>
@@ -511,7 +513,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Work Sessions
{t('workSessions')}
</Typography>
</Box>
<Box>
@@ -565,7 +567,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Total Time
{t('totalTime')}
</Typography>
</Box>
<Box>
@@ -597,12 +599,12 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
>
Work vs Break Distribution
{t('distribution')}
</Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{calculateCurrentActiveDuration() > 0
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
: 'No active time yet'}
: t('noActiveTime')}
</Typography>
</Box>
<Box
@@ -630,7 +632,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{/* Time Session */}
<Box>
<Typography level='h4' sx={{ mb: 2 }}>
Session Breakdown
{t('sessionBreakdown')}
</Typography>
<Box>
@@ -644,7 +646,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-sm'
sx={{ fontWeight: 'bold', mb: 2 }}
>
Work Sessions ({timerData.pauseLog.length})
{t('workSessions')} ({timerData.pauseLog.length})
</Typography>
<Box
@@ -795,7 +797,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-sm'
sx={{ fontWeight: 'bold' }}
>
Sessions
{t('sessions')}
</Typography>
<Button
size='sm'
@@ -803,7 +805,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
startDecorator={<Add />}
onClick={() => addPauseLogEntry(timerData.id)}
>
Add Session
{t('addSession')}
</Button>
</Box>
@@ -855,7 +857,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ fontWeight: 'bold' }}
>
Start Time
{t('startTime')}
</Typography>
<Input
type='datetime-local'
@@ -878,7 +880,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ fontWeight: 'bold' }}
>
End Time
{t('endTime')}
</Typography>
<Input
type='datetime-local'
@@ -903,7 +905,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
}
/>
<FormHelperText>
Leave empty if session is ongoing
{t('leaveEmpty')}
</FormHelperText>
</FormControl>
@@ -941,7 +943,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{!timerData && (
<Alert color='neutral' sx={{ mt: 2 }}>
No timer session found for this chore.
{t('noSessionForChore')}
</Alert>
)}
</Box>