Merge pull request #73 from donetick/feature/internationalization-support

Feature/internationalization support
This commit is contained in:
Mohamad Tarbin
2026-04-06 21:29:31 -04:00
committed by GitHub
51 changed files with 2515 additions and 309 deletions

View File

@@ -1,5 +1,6 @@
import { AlertsProvider } from '../service/AlertsProvider'
import { NotificationProvider } from '../service/NotificationProvider'
import { LocalizationProvider } from './LocalizationContext'
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
import ThemeContext from './ThemeContext'
@@ -8,6 +9,7 @@ const Contexts = ({ children }) => {
const contexts = [
AlertsProvider,
ThemeContext,
LocalizationProvider,
QueryContext,
NotificationProvider,
RouterContext,

View File

@@ -0,0 +1,127 @@
import useStickyState from '@/hooks/useStickyState'
import moment from 'moment'
import { createContext, useContext, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
const LocalizationContext = createContext()
export const DATE_FORMATS = {
MDY: 'MM/DD/YYYY',
DMY: 'DD/MM/YYYY',
YMD: 'YYYY-MM-DD',
LONG: 'MMMM D, YYYY',
SHORT: 'MMM D, YYYY',
}
export const TIME_FORMATS = {
HOUR_12: 'h:mm A',
HOUR_24: 'HH:mm',
}
export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
export const AVAILABLE_LANGUAGES = [
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
{ code: 'fr', name: 'French', nativeName: 'Français' },
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
]
export const LocalizationProvider = ({ children }) => {
const { i18n } = useTranslation()
const [dateFormat, setDateFormat] = useStickyState(
DATE_FORMATS.MDY,
'dateFormat',
)
const [timeFormat, setTimeFormat] = useStickyState(
TIME_FORMATS.HOUR_12,
'timeFormat',
)
const [firstDayOfWeek, setFirstDayOfWeek] = useStickyState(
0,
'firstDayOfWeek',
) // 0 = Sunday, 1 = Monday
const [language, setLanguage] = useStickyState('en', 'language')
useEffect(() => {
i18n.changeLanguage(language)
moment.locale(language)
}, [language, i18n])
useEffect(() => {
const isRTL = RTL_LANGUAGES.includes(language)
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
document.documentElement.lang = language
}, [language])
const formatDate = (date, format = dateFormat) => {
if (!date) return ''
return moment(date).format(format)
}
const formatDateTime = (date, format) => {
if (!date) return ''
const dateTimeFormat = format || `${dateFormat} ${timeFormat}`
return moment(date).format(dateTimeFormat)
}
const formatTime = (date, format = timeFormat) => {
if (!date) return ''
return moment(date).format(format)
}
const formatRelative = date => {
if (!date) return ''
return moment(date).fromNow()
}
const formatCalendar = date => {
if (!date) return ''
return moment(date).calendar(null, {
sameDay: `[Today] ${timeFormat}`,
nextDay: `[Tomorrow] ${timeFormat}`,
nextWeek: `dddd ${timeFormat}`,
lastDay: `[Yesterday] ${timeFormat}`,
lastWeek: `[Last] dddd ${timeFormat}`,
sameElse: `${dateFormat} ${timeFormat}`,
})
}
const isRTL = RTL_LANGUAGES.includes(language)
const fmt = {
date: formatDate,
dateTime: formatDateTime,
time: formatTime,
relative: formatRelative,
calendar: formatCalendar,
}
const value = {
dateFormat,
setDateFormat,
timeFormat,
setTimeFormat,
firstDayOfWeek,
setFirstDayOfWeek,
language,
setLanguage,
isRTL,
fmt,
availableLanguages: AVAILABLE_LANGUAGES,
}
return (
<LocalizationContext.Provider value={value}>
{children}
</LocalizationContext.Provider>
)
}
export const useLocalization = () => {
const context = useContext(LocalizationContext)
if (!context) {
throw new Error('useLocalization must be used within LocalizationProvider')
}
return context
}

View File

@@ -31,6 +31,7 @@ import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import ProjectView from '../views/Projects/ProjectView'
import APITokenSettings from '../views/Settings/APITokenSettings'
import LocalizationSettings from '../views/Settings/LocalizationSettings'
import MFASettings from '../views/Settings/MFASettings'
import NotificationSetting from '../views/Settings/NotificationSetting'
import ProfileSettings from '../views/Settings/ProfileSettings'
@@ -115,6 +116,10 @@ const Router = createBrowserRouter([
path: 'theme',
element: <ThemeSettings />,
},
{
path: 'localization',
element: <LocalizationSettings />,
},
{
path: 'advanced',
element: <AdvancedSettings />,

117
src/i18n/README.md Normal file
View File

@@ -0,0 +1,117 @@
# Internationalization (i18n) Setup
This directory contains the internationalization configuration for Donetick.
## Structure
```
src/i18n/
├── config.js # i18next configuration
└── README.md # This file
public/locales/
├── en/ # English (default)
│ ├── common.json
│ ├── settings.json
│ └── chores.json
├── es/ # Spanish
├── ar/ # Arabic (RTL)
└── ...
```
## Usage in Components
### Using translations
```jsx
import { useTranslation } from 'react-i18next'
function MyComponent() {
const { t } = useTranslation('settings') // or 'common', 'chores'
return <h1>{t('title')}</h1>
}
```
### Using date formatting
```jsx
import { useLocalization } from '@/contexts/LocalizationContext'
function MyComponent() {
const { formatDate, formatDateTime, formatRelative } = useLocalization()
const date = new Date()
return (
<div>
<p>Date: {formatDate(date)}</p>
<p>DateTime: {formatDateTime(date)}</p>
<p>Relative: {formatRelative(date)}</p>
</div>
)
}
```
### Using language/format settings
```jsx
import { useLocalization } from '@/contexts/LocalizationContext'
function MyComponent() {
const {
language,
setLanguage,
dateFormat,
setDateFormat,
isRTL
} = useLocalization()
return (
<div dir={isRTL ? 'rtl' : 'ltr'}>
Current language: {language}
</div>
)
}
```
## Available Namespaces
- **common**: General UI elements (buttons, messages, etc.)
- **settings**: Settings page translations
- **chores**: Chores-related translations
## Adding New Translations
1. Add the text to the appropriate JSON file in `public/locales/en/`
2. Use the translation in your component with `t('key')`
3. Upload to translation platform for community translation
## RTL Support
Languages in the `RTL_LANGUAGES` array automatically get:
- `dir="rtl"` on the document
- RTL-specific CSS styles
- Proper text alignment
Currently supported RTL languages: Arabic (ar), Hebrew (he), Persian (fa), Urdu (ur)
## Date Format Preferences
Users can choose from:
- MM/DD/YYYY (US)
- DD/MM/YYYY (Europe)
- YYYY-MM-DD (ISO)
- Long format (January 1, 2024)
- Short format (Jan 1, 2024)
## Time Format Preferences
- 12-hour (with AM/PM)
- 24-hour
## First Day of Week
Users can choose:
- Sunday
- Monday

36
src/i18n/config.js Normal file
View File

@@ -0,0 +1,36 @@
import i18n from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import HttpBackend from 'i18next-http-backend'
import { initReactI18next } from 'react-i18next'
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: import.meta.env.DEV,
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
ns: ['common', 'settings', 'chores'],
defaultNS: 'common',
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'i18nextLng',
},
react: {
useSuspense: true,
},
})
export default i18n

View File

@@ -54,3 +54,70 @@ html {
.animate-optimized.animation-complete {
will-change: auto;
}
/* RTL Support */
[dir='rtl'] {
direction: rtl;
text-align: right;
}
[dir='rtl'] .rtl-mirror {
transform: scaleX(-1);
}
/* Handle margins and paddings for RTL */
[dir='rtl'] .ml-auto {
margin-left: 0;
margin-right: auto;
}
[dir='rtl'] .mr-auto {
margin-right: 0;
margin-left: auto;
}
/* Flip icons and arrows in RTL */
[dir='rtl'] .rtl-flip {
transform: scaleX(-1);
}
/* Ensure proper text alignment in RTL */
[dir='rtl'] input,
[dir='rtl'] textarea {
text-align: right;
}
/* Handle border radius for RTL */
[dir='rtl'] .rounded-l-none {
border-radius: 0 0.375rem 0.375rem 0;
}
[dir='rtl'] .rounded-r-none {
border-radius: 0.375rem 0 0 0.375rem;
}
/* Fix flex alignment for RTL */
[dir='rtl'] .flex {
direction: rtl;
}
/* Ensure cards and containers align properly in RTL */
[dir='rtl'] .MuiCard-root,
[dir='rtl'] .MuiBox-root,
[dir='rtl'] .MuiStack-root {
text-align: right;
}
/* Fix list item alignment in RTL */
[dir='rtl'] .MuiListItem-root,
[dir='rtl'] .MuiListItemButton-root {
flex-direction: row-reverse;
}
/* Fix gap alignment in RTL */
[dir='rtl'] .gap-1,
[dir='rtl'] .gap-2,
[dir='rtl'] .gap-3,
[dir='rtl'] .gap-4 {
direction: rtl;
}

View File

@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import Contexts from './contexts/Contexts.jsx'
import './i18n/config'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(

View File

@@ -19,12 +19,22 @@ const allMonths = [
* @param {Object} chore - The chore object (needed for nextDueDate null check)
* @returns {string} The formatted due date text
*/
export const getDueDateChipText = (nextDueDate, chore) => {
export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') => {
if (chore?.nextDueDate === null || nextDueDate === null) return 'No Due Date'
const dueDate = moment(nextDueDate)
const diff = moment(nextDueDate).diff(moment(), 'hours')
const calendarFormat = {
sameDay: `[Today] ${timeFormat}`,
nextDay: `[Tomorrow] ${timeFormat}`,
nextWeek: `dddd ${timeFormat}`,
lastDay: `[Yesterday] ${timeFormat}`,
lastWeek: `[Last] dddd ${timeFormat}`,
sameElse: `MMM D ${timeFormat}`,
}
// if time is 23:59:59, treat as end-of-day (date only, no specific time)
if (dueDate.hours() === 23 && dueDate.minutes() === 59 && dueDate.seconds() === 59) {
if (diff < 0) {
@@ -33,22 +43,22 @@ export const getDueDateChipText = (nextDueDate, chore) => {
if (absDiff <= 48) {
return (
'Overdue ' +
moment(nextDueDate).calendar().split(' at ')[0].toLowerCase()
moment(nextDueDate).calendar(null, calendarFormat).split(' ')[0].toLowerCase()
)
}
return 'Overdue ' + dueDate.fromNow()
}
// if due in next 48 hours, show calendar format without time (e.g., "Tomorrow")
if (diff < 48 && diff > 0) {
return moment(nextDueDate).calendar().split(' at ')[0]
return moment(nextDueDate).calendar(null, calendarFormat).split(' ')[0]
}
// if due date is after 48 hours, show it in format: Due in 3 days
return 'Due ' + dueDate.fromNow()
}
// if due in next 48 hours, we should show it in this format: Tomorrow 11:00 AM
// if due in next 48 hours, we should show it in this format: Tomorrow 11:00
if (diff < 48 && diff > 0) {
return moment(nextDueDate).calendar().replace(' at', '')
return moment(nextDueDate).calendar(null, calendarFormat)
}
return 'Due ' + moment(nextDueDate).fromNow()
}

View File

@@ -0,0 +1,83 @@
import moment from 'moment'
export const createDateFormatter = (
dateFormat,
timeFormat,
firstDayOfWeek,
) => {
moment.updateLocale('en', {
week: {
dow: firstDayOfWeek,
},
})
return {
formatDate: (date, customFormat) => {
if (!date) return ''
return moment(date).format(customFormat || dateFormat)
},
formatDateTime: (date, customFormat) => {
if (!date) return ''
const format = customFormat || `${dateFormat} ${timeFormat}`
return moment(date).format(format)
},
formatTime: (date, customFormat) => {
if (!date) return ''
return moment(date).format(customFormat || timeFormat)
},
formatRelative: date => {
if (!date) return ''
return moment(date).fromNow()
},
formatCalendar: (date, opts) => {
if (!date) return ''
return moment(date).calendar(null, opts)
},
formatShortDate: date => {
if (!date) return ''
return moment(date).format('MMM D, YYYY')
},
formatLongDate: date => {
if (!date) return ''
return moment(date).format('MMMM D, YYYY')
},
isBefore: (date, compareDate) => {
return moment(date).isBefore(compareDate)
},
isAfter: (date, compareDate) => {
return moment(date).isAfter(compareDate)
},
diff: (date1, date2, unit) => {
return moment(date1).diff(moment(date2), unit)
},
add: (date, amount, unit) => {
return moment(date).add(amount, unit).toDate()
},
subtract: (date, amount, unit) => {
return moment(date).subtract(amount, unit).toDate()
},
}
}
export const useDateFormatter = () => {
if (typeof window === 'undefined') {
return createDateFormatter('MM/DD/YYYY', 'h:mm A', 0)
}
const dateFormat = localStorage.getItem('dateFormat') || 'MM/DD/YYYY'
const timeFormat = localStorage.getItem('timeFormat') || 'h:mm A'
const firstDayOfWeek = parseInt(localStorage.getItem('firstDayOfWeek') || '0')
return createDateFormatter(dateFormat, timeFormat, firstDayOfWeek)
}

View File

@@ -39,9 +39,11 @@ import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
import {
useChoreTimer,
@@ -75,6 +77,8 @@ import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
const ChoreView = () => {
const { t } = useTranslation('chores')
const { fmt } = useLocalization()
const [chore, setChore] = useState({})
const navigate = useNavigate()
const [performers, setPerformers] = useState([])
@@ -143,13 +147,13 @@ const ChoreView = () => {
{
size: 6,
icon: <PeopleAlt />,
title: 'Assignment',
text: `Assigned: ${
title: t('choreView.assignment'),
text: `${t('choreView.assigned')}: ${
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
'N/A'
t('choreView.na')
}`,
subtext: ` Last: ${
chore.lastCompletedDate && chore.lastCompletedBy
subtext: ` ${t('choreView.last')}: ${
chore.lastCompletedDate
? performers.find(p => p.userId === chore.lastCompletedBy)
?.displayName
: 'N/A'
@@ -158,14 +162,14 @@ const ChoreView = () => {
{
size: 6,
icon: <CalendarMonth />,
title: 'Schedule',
text: `Due: ${
chore.nextDueDate ? moment(chore.nextDueDate).fromNow() : 'N/A'
title: t('choreView.schedule'),
text: `${t('choreView.due')}: ${
chore.nextDueDate ? moment(chore.nextDueDate).fromNow() : t('choreView.na')
}`,
subtext: `Last: ${
subtext: `${t('choreView.last')}: ${
chore.lastCompletedDate
? moment(chore.lastCompletedDate).fromNow()
: 'N/A'
: t('choreView.na')
}`,
subtext2:
@@ -176,16 +180,16 @@ const ChoreView = () => {
{
size: 6,
icon: <Checklist />,
title: 'Statistics',
text: `Completed: ${chore.totalCompletedCount || 0} times`,
title: t('choreView.statistics'),
text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`,
},
{
size: 6,
icon: <Person />,
title: 'Details',
subtext: `Created By: ${
title: t('choreView.details'),
subtext: `${t('choreView.createdBy')}: ${
performers.find(p => p.userId === chore.createdBy)?.displayName ||
'N/A'
t('choreView.na')
}`,
},
]
@@ -225,8 +229,8 @@ const ChoreView = () => {
.then(() => {
// Show undo notification
showSuccess({
title: 'Task Completed',
message: 'Your task has been marked as complete',
title: t('choreView.taskCompleted'),
message: t('choreView.taskCompletedMessage'),
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(choreId)
@@ -239,16 +243,16 @@ const ChoreView = () => {
queryClient.invalidateQueries(['chores'])
}
showUndo({
title: 'Undo Successful',
message: 'Task completion has been undone.',
title: t('choreView.undoSuccessful'),
message: t('choreView.taskCompletionUndone'),
})
} else {
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: 'Undo Failed',
message: 'Unable to undo the action. Please try again.',
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
@@ -266,7 +270,7 @@ const ChoreView = () => {
// Show undo notification
showSuccess({
message: 'Task skipped',
message: t('choreView.skipTask'),
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(choreId)
@@ -279,16 +283,16 @@ const ChoreView = () => {
queryClient.invalidateQueries(['chores'])
}
showUndo({
title: 'Undo Successful',
message: 'Task skip has been undone.',
title: t('choreView.undoSuccessful'),
message: t('choreView.taskSkipUndone'),
})
} else {
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: 'Undo Failed',
message: 'Unable to undo the action. Please try again.',
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
@@ -324,11 +328,10 @@ const ChoreView = () => {
const handleResetTimer = () => {
setTimerActionConfig({
isOpen: true,
title: 'Reset Timer',
message:
'Are you sure you want to reset the timer? This will clear all time records since you started the task.',
confirmText: 'Reset Timer',
cancelText: 'Cancel',
title: t('choreView.resetTimer'),
message: t('choreView.resetTimerConfirmation'),
confirmText: t('choreView.resetTimer'),
cancelText: t('common:cancel'),
onClose: confirmed => {
if (confirmed) {
resetChoreTimer.mutate(choreId, {
@@ -349,11 +352,10 @@ const ChoreView = () => {
const handleClearAllTime = () => {
setTimerActionConfig({
isOpen: true,
title: 'Clear All Time Records',
message:
'This will permanently delete all timers for this task and set it back to "not started".',
confirmText: 'Clear All Time',
cancelText: 'Cancel',
title: t('choreView.clearAllTimeRecords'),
message: t('choreView.clearAllTimeConfirmation'),
confirmText: t('choreView.clearAllTimeRecords'),
cancelText: t('common:cancel'),
onClose: async confirmed => {
if (confirmed) {
if (choreTimer?.res?.id) {
@@ -473,13 +475,13 @@ const ChoreView = () => {
color='warning'
sx={{ mb: 1 }}
>
Archived
{t('choreView.archive')}
</Chip>
)}
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
{chore.nextDueDate
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
: 'N/A'}
? `${t('choreView.due')} ${fmt.dateTime(chore.nextDueDate)}`
: t('choreView.na')}
</Chip>
<Box
sx={{
@@ -628,7 +630,7 @@ const ChoreView = () => {
variant='plain'
>
{chorePriority ? chorePriority.icon : <LowPriority />}
{chorePriority ? chorePriority.name : 'No Priority'}
{chorePriority ? chorePriority.name : t('choreView.noPriority')}
</MenuButton>
<Menu>
{Priorities.map((priority, index) => (
@@ -655,13 +657,13 @@ const ChoreView = () => {
}}
onClick={() => {
handleUpdatePriority({
name: 'No Priority',
name: t('choreView.noPriority'),
value: 0,
})
setChorePriority(null)
}}
>
No Priority
{t('choreView.noPriority')}
</MenuItem>
</Menu>
</Dropdown>
@@ -682,7 +684,7 @@ const ChoreView = () => {
}}
>
<History />
History
{t('choreView.history')}
</Button>
<Button
size='sm'
@@ -708,7 +710,7 @@ const ChoreView = () => {
{chore.description && (
<>
<Typography level='title-md' sx={{ mb: 1 }}>
Description :
{t('choreView.description')}
</Typography>
<Sheet
@@ -722,7 +724,7 @@ const ChoreView = () => {
onClick={() => {
setNoteViewerConfig({
isOpen: true,
title: 'Description',
title: t('choreView.descriptionTitle'),
content: chore.description,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
@@ -754,7 +756,7 @@ const ChoreView = () => {
{chore.notes && (
<>
<Typography level='title-md' sx={{ mb: 1 }}>
Previous note:
{t('choreView.previousNoteLabel')}
</Typography>
<Sheet
variant='plain'
@@ -767,7 +769,7 @@ const ChoreView = () => {
onClick={() => {
setNoteViewerConfig({
isOpen: true,
title: 'Previous Note',
title: t('choreView.previousNote'),
content: chore.notes,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
@@ -798,7 +800,7 @@ const ChoreView = () => {
{chore.subTasks && chore.subTasks.length > 0 && (
<Box sx={{ p: 0, m: 0, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
Subtasks :
{t('choreView.subtasksLabel')}
</Typography>
<Sheet
variant='plain'
@@ -836,7 +838,7 @@ const ChoreView = () => {
variant='soft'
>
<Typography level='body-md' sx={{ mb: 1 }}>
Task Actions
{t('choreView.taskActions')}
</Typography>
<FormControl size='sm'>
@@ -860,7 +862,7 @@ const ChoreView = () => {
alignItems: 'center',
}}
>
Add a note
{t('choreView.addNote')}
</Typography>
}
/>
@@ -868,13 +870,13 @@ const ChoreView = () => {
{note !== null && (
<Box sx={{ mb: 1 }}>
<Typography level='body-sm' sx={{ mb: 1 }}>
Additional Notes:
{t('choreView.additionalNotes')}
</Typography>
<RichTextEditor
value={note || ''}
onChange={setNote}
entityType={'chore_completion_note'}
placeholder='Add a note about the completion...'
placeholder={t('choreView.notePlaceholder')}
/>
</Box>
)}
@@ -908,7 +910,7 @@ const ChoreView = () => {
alignItems: 'center',
}}
>
Set custom completion time
{t('choreView.setCustomCompletionTime')}
</Typography>
}
/>
@@ -942,7 +944,7 @@ const ChoreView = () => {
color='primary'
startDecorator={<Unarchive />}
>
Unarchive
{t('choreView.unarchive')}
</Button>
</Box>
) : (
@@ -980,7 +982,7 @@ const ChoreView = () => {
flex: 1,
}}
>
Approve
{t('choreView.approve')}
</Button>
<Button
fullWidth
@@ -992,7 +994,7 @@ const ChoreView = () => {
flex: 1,
}}
>
<Box>Reject</Box>
<Box>{t('choreView.reject')}</Box>
</Button>
</>
) : (
@@ -1003,7 +1005,7 @@ const ChoreView = () => {
color='neutral'
startDecorator={<HourglassEmpty />}
>
<Box>Pending Approval</Box>
<Box>{t('choreView.pendingApproval')}</Box>
</Button>
)
) : (
@@ -1022,7 +1024,7 @@ const ChoreView = () => {
flex: 4,
}}
>
<Box>Mark as done</Box>
<Box>{t('choreView.markAsDone')}</Box>
</Button>
<Button
@@ -1031,12 +1033,10 @@ const ChoreView = () => {
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: 'Skip Task',
message: 'Are you sure you want to skip this task?',
confirmText: 'Skip',
cancelText: 'Cancel',
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
@@ -1053,7 +1053,7 @@ const ChoreView = () => {
flex: 1,
}}
>
<Box>Skip</Box>
<Box>{t('choreView.skip')}</Box>
</Button>
</>
)}
@@ -1108,7 +1108,7 @@ const ChoreView = () => {
flex: 1,
}}
>
Start
{t('choreView.start')}
</Button>
)}
</Box>

View File

@@ -19,6 +19,7 @@ import {
import moment from 'moment'
import { useEffect } from 'react'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import ThingTriggerSection from './ThingTriggerSection'
@@ -76,7 +77,7 @@ const DAY_OCCURRENCE_OPTIONS = [
{ value: -1, label: 'Last occurrence' },
]
// Helper function to generate schedule preview text
const generateSchedulePreview = metadata => {
const generateSchedulePreview = (metadata, formatTimeFn) => {
if (!metadata?.days?.length) return ''
const dayNames = metadata.days
@@ -84,7 +85,7 @@ const generateSchedulePreview = metadata => {
.join(', ')
const timeStr = metadata.time
? moment(metadata.time).format('h:mm A')
? formatTimeFn(metadata.time)
: '6:00 PM'
if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
@@ -114,6 +115,7 @@ const RepeatOnSections = ({
frequencyMetadata,
onFrequencyMetadataUpdate,
}) => {
const { fmt } = useLocalization()
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
// otherwise set it to 18:00 of the current day
useEffect(() => {
@@ -400,7 +402,7 @@ const RepeatOnSections = ({
{frequencyMetadata?.days?.length > 0 && (
<Card mt={2} p={2}>
<Typography level='body-sm' color='primary'>
{generateSchedulePreview(frequencyMetadata)}
{generateSchedulePreview(frequencyMetadata, fmt.time)}
</Typography>
</Card>
)}

View File

@@ -8,9 +8,11 @@ import {
import { Box, Card, Chip, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
const navigate = useNavigate()
const { fmt } = useLocalization()
const [time, setTime] = useState(0)
const [shouldAnimate, setShouldAnimate] = useState(false)
const [prevStatus, setPrevStatus] = useState(null) // Initialize as null
@@ -187,10 +189,7 @@ const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
size='md'
startDecorator={<Flag sx={{ fontSize: 14 }} />}
>
{new Date(chore.startTime).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{fmt.time(chore.startTime)}
</Chip>
)}
@@ -202,10 +201,7 @@ const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
size='md'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{fmt.time(chore.timerUpdatedAt)}
</Chip>
)}
</>
@@ -219,10 +215,7 @@ const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
size='md'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{fmt.time(chore.timerUpdatedAt)}
</Chip>
)}
</Box>

View File

@@ -21,6 +21,7 @@ import {
Typography,
} from '@mui/joy'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import {
getDueDateChipColor,
@@ -45,6 +46,7 @@ const ChoreCard = ({
onSelectionToggle,
}) => {
const { data: userProfile } = useUserProfile()
const { timeFormat } = useLocalization()
const { impersonatedUser } = useImpersonateUser()
@@ -95,7 +97,7 @@ const ChoreCard = ({
}}
color={getDueDateChipColor(chore.nextDueDate, chore)}
>
{getDueDateChipText(chore.nextDueDate, chore)}
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
</Chip>
<Chip

View File

@@ -11,6 +11,7 @@ import {
import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import {
getDueDateChipColor,
@@ -41,6 +42,7 @@ const CompactChoreCard = ({
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const { timeFormat } = useLocalization()
const { data: circleMembersData } = useCircleMembers()
const { impersonatedUser } = useImpersonateUser()
@@ -395,7 +397,7 @@ const CompactChoreCard = ({
ml: 1,
}}
>
{getDueDateChipText(chore.nextDueDate, chore)}
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
</Chip>
</Box>

View File

@@ -22,6 +22,7 @@ import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import {
useChoreHistory,
@@ -44,6 +45,7 @@ const ChoreHistory = () => {
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [editHistory, setEditHistory] = useState(null)
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const { fmt } = useLocalization()
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification()
@@ -367,7 +369,7 @@ const ChoreHistory = () => {
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
title: `Updated at ${moment(historyEntry.updatedAt).format('LLLL')}`,
title: `Updated at ${fmt.dateTime(historyEntry.updatedAt)}`,
content: notes,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})

View File

@@ -15,6 +15,7 @@ import {
} from '@mui/icons-material'
import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx'
const getCompletedChip = historyEntry => {
@@ -96,6 +97,7 @@ const HistoryCard = ({
onToggleActions,
onViewNote,
}) => {
const { fmt } = useLocalization()
const performer = performers.find(p => p.userId === historyEntry.completedBy)
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
@@ -198,13 +200,12 @@ const HistoryCard = ({
? 'Rescheduled'
: 'Completed'}
</Typography>
{historyEntry.performedAt && (
<Chip size='sm' startDecorator={<EventNote />}>
{moment(
historyEntry.performedAt || historyEntry.updatedAt,
).format('MMM DD, h:mm A')}
</Chip>
)}
<Chip size='sm' startDecorator={<EventNote />}>
{fmt.dateTime(
historyEntry.performedAt || historyEntry.updatedAt,
)}
</Chip>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
@@ -224,7 +225,7 @@ const HistoryCard = ({
>
{historyEntry.dueDate && (
<Chip size='sm' startDecorator={<CalendarMonth />}>
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
{fmt.dateTime(historyEntry.dueDate)}
</Chip>
)}
</Box>

View File

@@ -12,6 +12,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useLocalization } from '../../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useNotification } from '../../../service/NotificationProvider'
import {
@@ -23,6 +24,7 @@ import ConfirmationModal from './ConfirmationModal'
const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
const [timerData, setTimerData] = useState(null)
const [loading, setLoading] = useState(false)
@@ -632,11 +634,9 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const sessionDate = moment(pause.start).format(
'MMM DD',
)
const startTime = moment(pause.start).format(
'HH:mm',
)
const startTime = fmt.time(pause.start)
const endTime = pause.end
? moment(pause.end).format('HH:mm')
? fmt.time(pause.end)
: null
const realTimeDuration = isOngoing

View File

@@ -12,6 +12,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
@@ -27,6 +28,7 @@ import SettingsLayout from './SettingsLayout'
const APITokenSettings = () => {
const { data: userProfile } = useUserProfile()
const { showNotification } = useNotification()
const { fmt } = useLocalization()
const [tokens, setTokens] = useState([])
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
const [showTokenId, setShowTokenId] = useState(null)
@@ -106,7 +108,7 @@ const APITokenSettings = () => {
<Typography level='body-md'>{token.name}</Typography>
<Typography level='body-xs'>
{moment(token.createdAt).fromNow()}(
{moment(token.createdAt).format('lll')})
{fmt.dateTime(token.createdAt)})
</Typography>
</Box>
<Box>

View File

@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import SubscriptionModal from '../../components/SubscriptionModal'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { CancelSubscription, UpdatePassword } from '../../utils/Fetcher'
@@ -17,6 +18,7 @@ const AccountSettings = () => {
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const { fmt } = useLocalization()
const [changePasswordModal, setChangePasswordModal] = useState(false)
const [subscriptionModal, setSubscriptionModal] = useState(false)
@@ -37,13 +39,9 @@ const AccountSettings = () => {
const getSubscriptionDetails = () => {
if (userProfile?.subscription === 'active') {
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${fmt.date(userProfile?.expiration)}.`
} else if (userProfile?.subscription === 'cancelled') {
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${fmt.date(userProfile?.expiration)}.`
} else {
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
}
@@ -54,9 +52,7 @@ const AccountSettings = () => {
return `Plus`
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return `Plus(until ${moment(userProfile?.expiration).format(
'MMM DD, YYYY',
)})`
return `Plus(until ${fmt.date(userProfile?.expiration)})`
}
return `Free`
} else {

View File

@@ -15,6 +15,7 @@ import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
@@ -36,6 +37,7 @@ const CircleSettings = () => {
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const navigate = useNavigate()
const { fmt } = useLocalization()
const [userCircles, setUserCircles] = useState([])
const [circleMemberRequests, setCircleMemberRequests] = useState([])
@@ -221,12 +223,12 @@ const CircleSettings = () => {
</Typography>
{member.isActive ? (
<Typography level='body-sm'>
Joined on {moment(member.createdAt).format('MMM DD, YYYY')}
Joined on {fmt.date(member.createdAt)}
</Typography>
) : (
<Typography level='body-sm' color='danger'>
Request to join{' '}
{moment(member.updatedAt).format('MMM DD, YYYY')}
{fmt.date(member.updatedAt)}
</Typography>
)}
</Box>
@@ -365,7 +367,7 @@ const CircleSettings = () => {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{lastRefresh && (
<Typography level='body-sm' color='neutral'>
Last updated: {moment(lastRefresh).format('MMM DD, HH:mm')}
Last updated: {fmt.dateTime(lastRefresh)}
</Typography>
)}
<Button

View File

@@ -0,0 +1,196 @@
import {
DATE_FORMATS,
TIME_FORMATS,
useLocalization,
} from '@/contexts/LocalizationContext'
import {
Box,
Button,
ButtonGroup,
Divider,
FormControl,
FormHelperText,
Option,
Select,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useTranslation } from 'react-i18next'
import SettingsLayout from './SettingsLayout'
const LocalizationSettings = () => {
const { t } = useTranslation('settings')
const {
language,
setLanguage,
dateFormat,
setDateFormat,
timeFormat,
setTimeFormat,
firstDayOfWeek,
setFirstDayOfWeek,
availableLanguages,
isRTL,
} = useLocalization()
const sampleDate = moment('2024-01-15 14:30:00')
const dateFormatOptions = [
{ value: DATE_FORMATS.MDY, label: t('localization.formats.mdy') },
{ value: DATE_FORMATS.DMY, label: t('localization.formats.dmy') },
{ value: DATE_FORMATS.YMD, label: t('localization.formats.ymd') },
{ value: DATE_FORMATS.LONG, label: t('localization.formats.long') },
{ value: DATE_FORMATS.SHORT, label: t('localization.formats.short') },
]
return (
<SettingsLayout title='Localization'>
<div className='grid gap-4 py-4'>
<Typography level='body-md'>{t('localization.description')}</Typography>
<Typography level='h3'>{t('localization.language')}</Typography>
<Divider />
<Typography level='body-md'>
{t('localization.languageDescription')}
</Typography>
<FormControl>
<Select
value={language}
onChange={(_, value) => setLanguage(value)}
sx={{ maxWidth: '300px' }}
>
{availableLanguages.map(lang => (
<Option key={lang.code} value={lang.code}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography>{lang.nativeName}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
({lang.name})
</Typography>
</Box>
</Option>
))}
</Select>
{isRTL && (
<FormHelperText>
{t(
'localization.rtlNotice',
'This language uses right-to-left (RTL) text direction',
)}
</FormHelperText>
)}
</FormControl>
<Typography level='h3'>{t('localization.dateFormat')}</Typography>
<Divider />
<Typography level='body-md'>
{t('localization.dateFormatDescription')}
</Typography>
<FormControl>
<Select
value={dateFormat}
onChange={(_, value) => setDateFormat(value)}
sx={{ maxWidth: '300px' }}
>
{dateFormatOptions.map(option => (
<Option key={option.value} value={option.value}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
gap: 2,
}}
>
<Typography>{option.label}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{sampleDate.format(option.value)}
</Typography>
</Box>
</Option>
))}
</Select>
<FormHelperText>
Preview: {sampleDate.format(dateFormat)}
</FormHelperText>
</FormControl>
<Typography level='h3'>{t('localization.timeFormat')}</Typography>
<Divider />
<Typography level='body-md'>
{t('localization.timeFormatDescription')}
</Typography>
<FormControl>
<Select
value={timeFormat}
onChange={(_, value) => setTimeFormat(value)}
sx={{ maxWidth: '300px' }}
>
<Option value={TIME_FORMATS.HOUR_12}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
gap: 2,
}}
>
<Typography>{t('localization.12hour')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{sampleDate.format(TIME_FORMATS.HOUR_12)}
</Typography>
</Box>
</Option>
<Option value={TIME_FORMATS.HOUR_24}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
gap: 2,
}}
>
<Typography>{t('localization.24hour')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{sampleDate.format(TIME_FORMATS.HOUR_24)}
</Typography>
</Box>
</Option>
</Select>
<FormHelperText>
Preview: {sampleDate.format(timeFormat)}
</FormHelperText>
</FormControl>
<Typography level='h3'>{t('localization.firstDayOfWeek')}</Typography>
<Divider />
<Typography level='body-md'>
{t('localization.firstDayOfWeekDescription')}
</Typography>
<FormControl>
<ButtonGroup variant='outlined'>
<Button
variant={firstDayOfWeek === 0 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(0)}
>
{t('localization.sunday')}
</Button>
<Button
variant={firstDayOfWeek === 1 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(1)}
>
{t('localization.monday')}
</Button>
<Button
variant={firstDayOfWeek === 6 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(6)}
>
{t('localization.saturday')}
</Button>
</ButtonGroup>
</FormControl>
</div>
</SettingsLayout>
)
}
export default LocalizationSettings

View File

@@ -13,6 +13,7 @@ import { useQueryClient } from '@tanstack/react-query'
import imageCompression from 'browser-image-compression'
import { useRef, useState } from 'react'
import Cropper from 'react-easy-crop'
import { useTranslation } from 'react-i18next'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
@@ -22,6 +23,7 @@ import { getCroppedImg } from '../../utils/imageCropUtils'
import SettingsLayout from './SettingsLayout'
const ProfileSettings = () => {
const { t } = useTranslation('settings')
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const { showSuccess, showError } = useNotification()
@@ -92,13 +94,13 @@ const ProfileSettings = () => {
setPhotoURL(url)
showSuccess({
title: 'Photo Updated',
message: 'Your profile photo has been updated successfully!',
title: t('profile.photoUpdated'),
message: t('profile.photoUpdatedMessage'),
})
} catch (err) {
showError({
title: 'Upload Failed',
message: 'Failed to upload your photo. Please try again.',
title: t('profile.uploadFailed'),
message: t('profile.uploadFailedMessage'),
})
} finally {
setIsUploading(false)
@@ -118,8 +120,8 @@ const ProfileSettings = () => {
if (response.ok) {
showSuccess({
title: 'Profile Updated',
message: 'Your profile information has been saved successfully!',
title: t('profile.profileUpdated'),
message: t('profile.profileUpdatedMessage'),
})
} else {
throw new Error('Failed to update profile')
@@ -128,9 +130,8 @@ const ProfileSettings = () => {
console.log(err)
showError({
title: 'Update Failed',
message:
'Unable to update your profile. Please check your connection and try again.',
title: t('profile.updateFailed'),
message: t('profile.updateFailedMessage'),
})
} finally {
setIsSaving(false)
@@ -140,10 +141,10 @@ const ProfileSettings = () => {
// Helper to resolve photoURL with baseURL if needed
return (
<SettingsLayout title='Profile Settings'>
<SettingsLayout title={t('profile.title')}>
<div className='grid gap-4 py-4' id='profile'>
<Typography level='body-md'>
Update your display name and profile photo.
{t('profile.description')}
</Typography>
<Card
sx={{
@@ -163,7 +164,7 @@ const ProfileSettings = () => {
loading={isUploading}
sx={{ mb: 1 }}
>
Change Photo
{t('profile.changePhoto')}
</Button>
<input
ref={fileInputRef}
@@ -227,7 +228,7 @@ const ProfileSettings = () => {
size='md'
sx={{ mr: 1 }}
>
Save
{t('profile.save')}
</Button>
<Button
onClick={() => {
@@ -237,24 +238,24 @@ const ProfileSettings = () => {
variant='soft'
color='neutral'
>
Cancel
{t('profile.cancel')}
</Button>
</Box>
</ModalDialog>
</Modal>
<Box sx={{ maxWidth: 400, mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Display Name
{t('profile.displayName')}
</Typography>
<Input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder='Enter your display name'
placeholder={t('profile.displayNamePlaceholder')}
sx={{ mb: 2 }}
/>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Timezone
{t('profile.timezone')}
</Typography>
<Autocomplete
value={timezone}
@@ -283,7 +284,7 @@ const ProfileSettings = () => {
)
})
}}
placeholder='Select your timezone'
placeholder={t('profile.timezonePlaceholder')}
sx={{ mb: 2 }}
/>
@@ -294,7 +295,7 @@ const ProfileSettings = () => {
loading={isSaving}
sx={{ width: 120 }}
>
Save
{t('profile.save')}
</Button>
</Box>
</div>

View File

@@ -23,6 +23,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import RealTimeSettings from '../../components/RealTimeSettings'
import SubscriptionModal from '../../components/SubscriptionModal'
import { useLocalization } from '../../contexts/LocalizationContext'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
@@ -46,6 +47,7 @@ import NativeCancelSubscriptionModal from '../Modals/Inputs/NativeCancelSubscrip
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
import APITokenSettings from './APITokenSettings'
import LocalizationSettings from './LocalizationSettings'
import MFASettings from './MFASettings'
import NotificationSetting from './NotificationSetting'
import ProfileSettings from './ProfileSettings'
@@ -58,6 +60,7 @@ const Settings = () => {
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const navigate = useNavigate()
const { fmt } = useLocalization()
const [userCircles, setUserCircles] = useState([])
const [circleMemberRequests, setCircleMemberRequests] = useState([])
@@ -188,13 +191,13 @@ const Settings = () => {
const getSubscriptionDetails = () => {
if (userProfile?.subscription === 'active') {
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment(
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${fmt.date(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
)}.`
} else if (userProfile?.subscription === 'cancelled') {
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment(
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${fmt.date(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
)}.`
} else {
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
}
@@ -204,9 +207,7 @@ const Settings = () => {
return `Plus`
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return `Plus(until ${moment(userProfile?.expiration).format(
'MMM DD, YYYY',
)})`
return `Plus(until ${fmt.date(userProfile?.expiration)})`
}
return `Free`
} else {
@@ -339,12 +340,12 @@ const Settings = () => {
</Typography>
{member.isActive ? (
<Typography level='body-sm'>
Joined on {moment(member.createdAt).format('MMM DD, YYYY')}
Joined on {fmt.date(member.createdAt)}
</Typography>
) : (
<Typography level='body-sm' color='danger'>
Request to join{' '}
{moment(member.updatedAt).format('MMM DD, YYYY')}
{fmt.date(member.updatedAt)}
</Typography>
)}
</Box>
@@ -485,7 +486,7 @@ const Settings = () => {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{lastRefresh && (
<Typography level='body-sm' color='neutral'>
Last updated: {moment(lastRefresh).format('MMM DD, HH:mm')}
Last updated: {fmt.dateTime(lastRefresh)}
</Typography>
)}
<Button
@@ -916,6 +917,16 @@ const Settings = () => {
<ThemeToggle />
</div>
<div className='grid gap-4 py-4' id='localization'>
<Typography level='h3'>Localization</Typography>
<Divider />
<Typography level='body-md'>
Customize language, date format, and regional preferences for your
account. These settings will apply throughout the application.
</Typography>
<LocalizationSettings />
</div>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />

View File

@@ -5,6 +5,7 @@ import {
Circle,
Code,
FamilyRestroom,
Language,
Notifications,
Palette,
Person,
@@ -20,6 +21,7 @@ import {
Button,
Card,
CardContent,
Chip,
Container,
List,
ListItem,
@@ -29,98 +31,95 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers'
const SettingsOverview = () => {
const { t } = useTranslation('settings')
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const settingsCards = [
{
id: 'profile',
title: 'Profile Settings',
description:
'Update your profile information, photo, display name, and timezone preferences.',
title: t('overview.sections.profile.title'),
description: t('overview.sections.profile.description'),
icon: <Person />,
},
{
id: 'circle',
title: 'Circle Settings',
description:
'Manage your circle, invite members, and handle join requests.',
title: t('overview.sections.circle.title'),
description: t('overview.sections.circle.description'),
icon: <Circle />,
},
{
id: 'account',
title: 'Account Settings',
description:
'Manage your subscription, change password, and account deletion options.',
title: t('overview.sections.account.title'),
description: t('overview.sections.account.description'),
icon: <AccountCircle />,
},
{
id: 'subaccounts',
title: 'Managed Accounts',
description:
'Create and manage sub accounts to log in and complete assigned tasks.',
title: t('overview.sections.subaccounts.title'),
description: t('overview.sections.subaccounts.description'),
icon: <FamilyRestroom />,
},
{
id: 'notifications',
title: 'Notifications',
description:
'Configure push notifications, email alerts, and notification targets for tasks.',
title: t('overview.sections.notifications.title'),
description: t('overview.sections.notifications.description'),
icon: <Notifications />,
},
{
id: 'mfa',
title: 'Multi-Factor Authentication',
description:
'Add an extra layer of security with MFA using authenticator apps.',
title: t('overview.sections.mfa.title'),
description: t('overview.sections.mfa.description'),
icon: <Security />,
},
{
id: 'apitokens',
title: 'API Tokens',
description:
'Generate and manage access tokens for third-party integrations and API access.',
title: t('overview.sections.apitokens.title'),
description: t('overview.sections.apitokens.description'),
icon: <Api />,
},
{
id: 'storage',
title: 'Storage Settings',
description:
'Backup and restore your data, manage local storage and sync preferences.',
title: t('overview.sections.storage.title'),
description: t('overview.sections.storage.description'),
icon: <Storage />,
},
{
id: 'sidepanel',
title: 'Sidepanel Customization',
description:
'Customize the layout and visibility of cards in the sidepanel interface.',
title: t('overview.sections.sidepanel.title'),
description: t('overview.sections.sidepanel.description'),
icon: <ViewSidebar />,
},
{
id: 'theme',
title: 'Theme Preferences',
description:
'Choose your preferred theme and configure dark/light mode settings.',
title: t('overview.sections.theme.title'),
description: t('overview.sections.theme.description'),
icon: <Palette />,
},
{
id: 'localization',
title: t('overview.sections.localization.title'),
description: t('overview.sections.localization.description'),
icon: <Language />,
isBeta: true,
},
{
id: 'advanced',
title: 'Advanced Settings',
description:
'Configure webhooks, real-time updates, and other advanced features for enhanced productivity.',
title: t('overview.sections.advanced.title'),
description: t('overview.sections.advanced.description'),
icon: <Settings />,
},
{
id: 'developer',
title: 'Developer Settings',
description:
'View technical information about authentication tokens, SSE connections, and debug data.',
title: t('overview.sections.developer.title'),
description: t('overview.sections.developer.description'),
icon: <Code />,
},
]
@@ -159,10 +158,10 @@ const SettingsOverview = () => {
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Settings
{t('overview.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Customize your experience and manage your account preferences
{t('overview.subtitle')}
</Typography>
</Stack>
</Box>
@@ -226,7 +225,7 @@ const SettingsOverview = () => {
fontSize: { xs: '0.95rem', md: '1.25rem' },
}}
>
Upgrade to Plus
{t('overview.upgrade.title')}
</Typography>
<Typography
level='body-md'
@@ -237,7 +236,7 @@ const SettingsOverview = () => {
lineHeight: { xs: 1.3, md: 1.5 },
}}
>
Unlock powerful features to enhance your productivity
{t('overview.upgrade.description')}
</Typography>
<Box
sx={{
@@ -248,10 +247,16 @@ const SettingsOverview = () => {
color: 'rgba(255, 255, 255, 0.8)',
}}
>
<span> Rich text descriptions</span>
<span> Task notifications</span>
<span> API integrations</span>
<span> Advanced automation</span>
<span> {t('overview.upgrade.features.richText')}</span>
<span>
{t('overview.upgrade.features.notifications')}
</span>
<span>
{t('overview.upgrade.features.apiIntegrations')}
</span>
<span>
{t('overview.upgrade.features.advancedAutomation')}
</span>
</Box>
</Box>
</Box>
@@ -279,7 +284,7 @@ const SettingsOverview = () => {
navigate('/settings/account')
}}
>
Upgrade Now
{t('overview.upgrade.button')}
</Button>
</Box>
</CardContent>
@@ -319,12 +324,33 @@ const SettingsOverview = () => {
</Avatar>
</ListItemDecorator>
<ListItemContent sx={{ ml: 2 }}>
<Typography
level='title-md'
sx={{ mb: 0.5, fontWeight: 'lg' }}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 0.5,
}}
>
{setting.title}
</Typography>
<Typography level='title-md' sx={{ fontWeight: 'lg' }}>
{setting.title}
</Typography>
{setting.isBeta && (
<Chip
variant='outlined'
size='sm'
sx={{
height: '20px',
fontSize: '0.65rem',
fontWeight: 'bold',
color: 'warning.main',
borderColor: 'warning.main',
}}
>
Early Access
</Chip>
)}
</Box>
<Typography
level='body-sm'
color='neutral'

View File

@@ -11,10 +11,12 @@ import {
ToggleButtonGroup,
useColorScheme,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
const ELEMENTID = 'select-theme-mode'
const ThemeToggle = () => {
const { t } = useTranslation('settings')
const { mode, setMode } = useColorScheme()
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
@@ -30,7 +32,7 @@ const ThemeToggle = () => {
id={`${ELEMENTID}-label`}
htmlFor='select-theme-mode'
>
Theme mode
{t('theme.themeMode')}
</FormLabel>
)
@@ -45,13 +47,13 @@ const ThemeToggle = () => {
onChange={handleThemeModeChange}
>
<Button startDecorator={<LightModeOutlined />} value='light'>
Light
{t('theme.light')}
</Button>
<Button startDecorator={<DarkModeOutlined />} value='dark'>
Dark
{t('theme.dark')}
</Button>
<Button startDecorator={<LaptopOutlined />} value='system'>
System
{t('theme.system')}
</Button>
</ToggleButtonGroup>
</div>

View File

@@ -26,6 +26,7 @@ import {
import { useTheme } from '@mui/joy/styles'
import moment from 'moment'
import { Link, useParams } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import {
Line,
LineChart,
@@ -40,6 +41,7 @@ import LoadingComponent from '../components/Loading'
const ThingsHistory = () => {
const { id } = useParams()
const theme = useTheme()
const { fmt } = useLocalization()
const {
data,
error,
@@ -294,9 +296,7 @@ const ThingsHistory = () => {
tick='false'
tickLine='false'
axisLine='false'
tickFormatter={tick =>
moment(tick).format('ddd MM/DD/yyyy HH:mm:ss')
}
tickFormatter={tick => fmt.dateTime(tick)}
/>
<YAxis
hide='true'
@@ -306,9 +306,7 @@ const ThingsHistory = () => {
axisLine='false'
/>
<Tooltip
labelFormatter={label =>
moment(label).format('ddd MM/DD/yyyy HH:mm:ss')
}
labelFormatter={label => fmt.dateTime(label)}
/>
<Line
@@ -399,7 +397,7 @@ const ThingsHistory = () => {
color='primary'
startDecorator={<Schedule />}
>
{moment(history.updatedAt).format('MMM DD, h:mm A')}
{fmt.dateTime(history.updatedAt)}
</Chip>
</Box>
</Grid>

View File

@@ -38,6 +38,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import {
useChoreTimer,
usePauseChore,
@@ -52,6 +53,7 @@ import LoadingComponent from '../components/Loading'
const TimerDetails = () => {
const { choreId } = useParams()
const { fmt } = useLocalization()
const [timerData, setTimerData] = useState(null)
const [loading, setLoading] = useState(false)
const [editingSessions, setEditingSessions] = useState({})
@@ -819,14 +821,14 @@ const TimerDetails = () => {
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Started: {moment(timerData.startTime).format('HH:mm')}
Started: {fmt.time(timerData.startTime)}
</Typography>
{timerData.endTime && (
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Ended: {moment(timerData.endTime).format('HH:mm')}
Ended: {fmt.time(timerData.endTime)}
</Typography>
)}
{!timerData.endTime && (
@@ -834,7 +836,7 @@ const TimerDetails = () => {
level='body-xs'
sx={{ color: 'success.500' }}
>
Now: {moment(currentTime).format('HH:mm')}
Now: {fmt.time(currentTime)}
</Typography>
)}
<Typography
@@ -925,9 +927,9 @@ const TimerDetails = () => {
const sessionDate = moment(pause.start).format(
'MMM DD',
)
const startTime = moment(pause.start).format('HH:mm')
const startTime = fmt.time(pause.start)
const endTime = pause.end
? moment(pause.end).format('HH:mm')
? fmt.time(pause.end)
: null
const realTimeDuration = isOngoing

View File

@@ -34,6 +34,7 @@ import {
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
@@ -142,7 +143,10 @@ const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onView
)
}
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
const { fmt } = useLocalization()
const groupedHistory = groupByDate(history)
const sortedEntries = Object.entries(groupedHistory).sort(
@@ -161,12 +165,7 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
{Object.entries(groupedHistory).map(([date, items]) => (
<Box key={date} sx={{ mb: 4 }}>
<Typography level='title-sm' sx={{ mb: 0.5 }}>
{new Date(date).toLocaleDateString([], {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
{fmt.date(date)}
</Typography>
<Divider />
<Stack spacing={1}>
@@ -174,12 +173,10 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
<>
<ChoreHistoryItem
key={record.id}
time={new Date(
time={fmt.time(
record.performedAt || record.updatedAt,
).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
)}
name={record.choreName}
points={record.points}
status={record.status}

View File

@@ -3,6 +3,7 @@ import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getPriorityColor, TASK_COLOR } from '../../utils/Colors'
@@ -15,6 +16,7 @@ const getAssigneeColor = (assignee, userProfile) => {
}
const CalendarCard = ({ chores }) => {
const { data: userProfile } = useUserProfile()
const { fmt } = useLocalization()
const [selectedDate, setSeletedDate] = useState(null)
const Navigate = useNavigate()
@@ -197,7 +199,7 @@ const CalendarCard = ({ chores }) => {
}}
>
<Typography level='title-md'>
{moment(selectedDate).format('MMMM D, YYYY')}
{fmt.date(selectedDate)}
</Typography>
<Chip variant='soft' color='primary' size='md'>
{(() => {
@@ -291,7 +293,7 @@ const CalendarCard = ({ chores }) => {
color: 'neutral.500',
}}
>
{moment(chore.nextDueDate).format('h:mm A')}
{fmt.time(chore.nextDueDate)}
</Typography>
{/* <Typography
level='body-xs'

View File

@@ -4,6 +4,7 @@ import Calendar from 'react-calendar'
import { useNavigate } from 'react-router-dom'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getPriorityColor, TASK_COLOR } from '../../utils/Colors'
import { useLocalization } from '../../contexts/LocalizationContext'
import styles from './CalendarDual.module.css'
const getAssigneeColor = (assignee, userProfile) => {
@@ -13,6 +14,9 @@ const getAssigneeColor = (assignee, userProfile) => {
}
const CalendarDual = ({ chores, onDateChange }) => {
const { data: userProfile } = useUserProfile()
const { firstDayOfWeek, fmt } = useLocalization()
const calendarType =
firstDayOfWeek === 1 ? 'iso8601' : firstDayOfWeek === 6 ? 'islamic' : 'gregory'
const [selectedDate, setSeletedDate] = useState(null)
const [currentDate, setCurrentDate] = useState(new Date())
@@ -96,16 +100,14 @@ const CalendarDual = ({ chores, onDateChange }) => {
{isSecondary && (
<div className={styles.secondaryCalendarHeader}>
<Typography level='title-md' sx={{ textAlign: 'center', mb: 1 }}>
{date.toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})}
{fmt.date(date, 'MMMM YYYY')}
</Typography>
</div>
)}
<Calendar
className={styles.reactCalendar}
tileContent={tileContent}
calendarType={calendarType}
onChange={d => {
let date = new Date(d)
setSeletedDate(date)

View File

@@ -1,7 +1,11 @@
import Calendar from 'react-calendar'
import { useLocalization } from '../../contexts/LocalizationContext'
import { getPriorityColor } from '../../utils/Colors'
import styles from './Calendar.module.css'
const CalendarMonthly = ({ chores, onDateChange }) => {
const { firstDayOfWeek } = useLocalization()
const calendarType =
firstDayOfWeek === 1 ? 'iso8601' : firstDayOfWeek === 6 ? 'islamic' : 'gregory'
const tileContent = ({ date, view }) => {
if (view === 'month') {
const dayChores = chores.filter(chore => {
@@ -51,6 +55,7 @@ const CalendarMonthly = ({ chores, onDateChange }) => {
<div className={styles.reactCalendar}>
<Calendar
tileContent={tileContent}
calendarType={calendarType}
onChange={d => {
onDateChange(new Date(d))
}}

View File

@@ -25,78 +25,12 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { version } from '../../../package.json'
import UserProfileAvatar from '../../components/UserProfileAvatar'
import { useLocalization } from '../../contexts/LocalizationContext'
import NavBarLink from './NavBarLink'
const links = [
{
to: '/chores',
label: 'All Tasks',
icon: <Inbox />,
},
{
to: '/archived',
label: 'Archived',
icon: <Archive />,
},
// {
// to: '/chores',
// label: 'Desktop View',
// icon: <ListAltRounded />,
// },
{
to: '/things',
label: 'Things',
icon: <Widgets />,
},
{
to: 'labels',
label: 'Labels',
icon: <ListAlt />,
},
{
to: 'projects',
label: 'Projects',
icon: <FolderOpen />,
},
{
to: 'filters',
label: 'Filters',
icon: <FilterAlt />,
},
{
to: 'activities',
label: 'Activities',
icon: <History />,
},
{
to: 'points',
label: 'Points',
icon: <Toll />,
},
// {
// to: '/settings#sharing',
// label: 'Sharing',
// icon: <ShareOutlined />,
// },
// {
// to: '/settings#notifications',
// label: 'Notifications',
// icon: <Message />,
// },
// {
// to: '/settings#account',
// label: 'Account',
// icon: <AccountBox />,
// },
{
to: '/settings',
label: 'Settings',
icon: <SettingsOutlined />,
},
]
import { SafeArea } from 'capacitor-plugin-safe-area'
import Z_INDEX from '../../constants/zIndex'
@@ -105,10 +39,60 @@ import { apiClient } from '../../utils/ApiClient'
const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => {
const { t } = useTranslation('common')
const { isRTL } = useLocalization()
const { data: resource } = useResource()
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const links = [
{
to: '/chores',
label: t('navigation.allTasks'),
icon: <Inbox />,
},
{
to: '/archived',
label: t('navigation.archived'),
icon: <Archive />,
},
{
to: '/things',
label: t('navigation.things'),
icon: <Widgets />,
},
{
to: 'labels',
label: t('navigation.labels'),
icon: <ListAlt />,
},
{
to: 'projects',
label: t('navigation.projects'),
icon: <FolderOpen />,
},
{
to: 'filters',
label: t('navigation.filters'),
icon: <FilterAlt />,
},
{
to: 'activities',
label: t('navigation.activities'),
icon: <History />,
},
{
to: 'points',
label: t('navigation.points'),
icon: <Toll />,
},
{
to: '/settings',
label: t('navigation.settings'),
icon: <SettingsOutlined />,
},
]
const [openDrawer, closeDrawer] = [
() => setDrawerOpen(true),
() => setDrawerOpen(false),
@@ -157,7 +141,7 @@ const NavBar = () => {
}
}}
title={
searchParams.get('from') === 'calendar' ? 'Back to Calendar' : 'Back'
searchParams.get('from') === 'calendar' ? t('backToCalendar') : t('back')
}
>
<ArrowBack />
@@ -221,13 +205,14 @@ const NavBar = () => {
<Drawer
open={drawerOpen}
onClose={closeDrawer}
anchor={isRTL ? 'right' : 'left'}
size='sm'
onClick={closeDrawer}
sx={{
'& .MuiDrawer-content': {
position: 'fixed',
// pt: 'calc(var(--safe-area-inset-top, 0px))',
left: 0,
...(isRTL ? { right: 0 } : { left: 0 }),
// pb: 'calc(var(--safe-area-inset-bottom, 0px))',
// height:
// 'calc(100vh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))',
@@ -297,7 +282,7 @@ const NavBar = () => {
<ListItemDecorator>
<Logout />
</ListItemDecorator>
<ListItemContent>Logout</ListItemContent>
<ListItemContent>{t('logout')}</ListItemContent>
</ListItemButton>
<Typography
onClick={

View File

@@ -32,6 +32,7 @@ import {
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { useUserProfile } from '../../queries/UserQueries'
import { CompleteSubTask } from '../../utils/Fetcher'
@@ -48,6 +49,7 @@ function SortableItem({
editMode,
performers = [],
}) {
const { fmt } = useLocalization()
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id: task.id,
@@ -210,7 +212,7 @@ function SortableItem({
fontSize: 'sm',
}}
>
{new Date(task.completedAt).toLocaleString()}
{fmt.dateTime(task.completedAt)}
{performers.find(p => p.userId === task.completedBy) ? (
<Chip>
{