diff --git a/public/locales/en/chores.json b/public/locales/en/chores.json
index 892905d..98675d7 100644
--- a/public/locales/en/chores.json
+++ b/public/locales/en/chores.json
@@ -273,7 +273,11 @@
"filterUpdated": "Filter Updated",
"filterUpdatedMsg": "\"{{name}}\" has been updated successfully",
"advancedFilterCreated": "Advanced Filter Created",
- "advancedFilterCreatedMsg": "\"{{name}}\" has been created successfully"
+ "advancedFilterCreatedMsg": "\"{{name}}\" has been created successfully",
+ "reject": "Reject",
+ "pending": "Pending",
+ "schedule": "Schedule",
+ "nudge": "Nudge"
},
"multiToolbar": {
"skip": "Skip",
@@ -282,7 +286,27 @@
"skipTitle": "Skip selected tasks (/)",
"archiveTitle": "Archive selected tasks (X)",
"archive": "Archive",
- "deleteTitle": "Delete selected tasks (Shift+X)"
+ "deleteTitle": "Delete selected tasks (Shift+X)",
+ "mixed": "Mixed",
+ "due": "Due",
+ "dueTitleSet": "Due date on selected tasks: {{value}}",
+ "dueTitleEmpty": "Set due date on selected tasks",
+ "presetToday": "Today",
+ "presetTomorrow": "Tomorrow",
+ "presetNextWeek": "Next week",
+ "pickDate": "Pick date…",
+ "noDueDate": "No due date",
+ "moveTitle": "Move selected tasks to a project",
+ "move": "Move",
+ "defaultProject": "Default Project",
+ "moreTitle": "Set priority, assignee, or labels",
+ "more": "More",
+ "priority": "Priority",
+ "assignee": "Assignee",
+ "labels": "Labels",
+ "labelsHint": "Tap to add · tap again to remove",
+ "done": "Done",
+ "noPriority": "None"
},
"remind": {
"title": "Reminders"
@@ -553,5 +577,231 @@
"useTask": "Use Task",
"processing": "Processing",
"create": "Create"
+ },
+ "repeat": {
+ "days": {
+ "monday": "Monday",
+ "tuesday": "Tuesday",
+ "wednesday": "Wednesday",
+ "thursday": "Thursday",
+ "friday": "Friday",
+ "saturday": "Saturday",
+ "sunday": "Sunday"
+ },
+ "months": {
+ "january": "January",
+ "february": "February",
+ "march": "March",
+ "april": "April",
+ "may": "May",
+ "june": "June",
+ "july": "July",
+ "august": "August",
+ "september": "September",
+ "october": "October",
+ "november": "November",
+ "december": "December"
+ },
+ "freqType": {
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "yearly": "Yearly",
+ "adaptive": "Adaptive",
+ "custom": "Custom"
+ },
+ "freqMessage": {
+ "adaptive": "This chore will be scheduled dynamically based on previous completion dates.",
+ "custom": "This chore will be scheduled based on a custom frequency."
+ },
+ "weekPattern": {
+ "every_week": "Every week",
+ "week_of_month": "Specific occurrences in the month"
+ },
+ "weekPatternHelp": {
+ "every_week": "Task repeats every week on selected days",
+ "week_of_month": "Task repeats on specific day occurrences each month (e.g., 1st Monday, 3rd Friday)"
+ },
+ "occurrence": {
+ "1": "1st occurrence",
+ "2": "2nd occurrence",
+ "3": "3rd occurrence",
+ "4": "4th occurrence",
+ "last": "Last occurrence"
+ },
+ "ordinal": {
+ "1": "1st",
+ "2": "2nd",
+ "3": "3rd",
+ "4": "4th",
+ "last": "last"
+ },
+ "unit": {
+ "hours": "Hours",
+ "days": "Days",
+ "weeks": "Weeks",
+ "months": "Months",
+ "years": "Years"
+ },
+ "repeatOnType": {
+ "interval": "Interval",
+ "days_of_the_week": "Days of the week",
+ "day_of_the_month": "Day of the month"
+ },
+ "timeOfDay": "Time of day: ",
+ "every": "Every: ",
+ "unitPlaceholder": "Unit",
+ "selectAll": "Select All",
+ "unselectAll": "Unselect All",
+ "selectOccurrences": "Select which occurrences of the selected days:",
+ "occurrenceExample": "Example: \"1st Monday\" means the first Monday of each month",
+ "onThe": "on the ",
+ "ofAboveMonths": "of the above month/s",
+ "title": "Repeat:",
+ "repeatThisTask": "Repeat this task",
+ "repeatThisTaskHelp": "Is this something needed to be done regularly?",
+ "howOften": "How often should it be repeated?",
+ "repeatOn": "Repeat on:",
+ "triggerLabel": "Trigger this task based on a thing state",
+ "triggerHelp": "Is this something that should be done when a thing state changes?",
+ "triggerPlanWarning": "Thing-based triggers are not available in the Basic plan. Upgrade to Plus to automatically trigger tasks when device states change.",
+ "previewEvery": "Every {{days}} at {{time}}",
+ "previewOccurrence": "Every {{occurrences}} {{days}} of the month at {{time}}",
+ "defaultTime": "6:00 PM",
+ "daysShort": {
+ "monday": "Mon",
+ "tuesday": "Tue",
+ "wednesday": "Wed",
+ "thursday": "Thu",
+ "friday": "Fri",
+ "saturday": "Sat",
+ "sunday": "Sun"
+ },
+ "monthsShort": {
+ "january": "Jan",
+ "february": "Feb",
+ "march": "Mar",
+ "april": "Apr",
+ "may": "May",
+ "june": "Jun",
+ "july": "Jul",
+ "august": "Aug",
+ "september": "Sep",
+ "october": "Oct",
+ "november": "Nov",
+ "december": "Dec"
+ },
+ "occurrenceShort": {
+ "1": "1st",
+ "2": "2nd",
+ "3": "3rd",
+ "4": "4th",
+ "last": "Last"
+ },
+ "picker": {
+ "modalTitle": "Repeat Schedule",
+ "trigger": "Repeat",
+ "clearAria": "Clear repeat schedule",
+ "remove": "Remove",
+ "apply": "Apply",
+ "frequency": "Frequency",
+ "scheduleType": "Schedule type",
+ "timeOfDay": "Time of day",
+ "repeatEvery": "Repeat every",
+ "days": "Days",
+ "pattern": "Pattern",
+ "everyWeek": "Every week",
+ "specificWeeks": "Specific weeks",
+ "occurrences": "Occurrences",
+ "months": "Months",
+ "dayOfMonth": "Day of month"
+ }
+ },
+ "advancedOptions": {
+ "points": "Points",
+ "pointsDescription": "Award points for completing this task",
+ "requireApproval": "Require approval",
+ "requireApprovalDescription": "Task needs admin sign-off before it can be closed",
+ "limitedVisibility": "Limited visibility",
+ "limitedVisibilityDisabled": "Assign someone to enable limited visibility",
+ "limitedVisibilityDescription": "Only you and assignees can see this task",
+ "assignStrategy": "Assign strategy",
+ "assignStrategyDescription": "How to pick the next assignee each recurrence",
+ "availableFrom": "Available from",
+ "availableFromDescription": "Hours before the due date the task becomes available",
+ "strategy": {
+ "keep_last_assigned": "Keep same assignee",
+ "random": "Random",
+ "least_completed": "Least completed",
+ "round_robin": "Round robin"
+ },
+ "hrs": "hrs",
+ "setDueDateHint": "Set a due date to configure completion window",
+ "more": "More"
+ },
+ "filterBuilder": {
+ "dueDateOption": {
+ "isOverdue": "Overdue",
+ "isDueToday": "Today",
+ "isDueTomorrow": "Tomorrow",
+ "isDueThisWeek": "This Week",
+ "isDueThisMonth": "This Month",
+ "hasNoDueDate": "No Due Date",
+ "hasDueDate": "Has Due Date"
+ },
+ "status": {
+ "0": "Active",
+ "1": "Started",
+ "2": "In Progress",
+ "3": "Pending Approval"
+ },
+ "include": "Include",
+ "exclude": "Exclude",
+ "has": "Has",
+ "doesntHave": "Doesn't Have",
+ "assignee": "Assignee",
+ "createdBy": "Created By",
+ "statusHeading": "Status",
+ "priority": "Priority",
+ "dueDate": "Due Date",
+ "labels": "Labels",
+ "projects": "Projects",
+ "defaultProject": "Default Project",
+ "points": "Points",
+ "project": "Project",
+ "filterFallback": "Filter",
+ "notPrefix": "Not ",
+ "customDueDate": "Custom",
+ "clear": "Clear"
+ },
+ "empty": {
+ "errorTitle": "Can't reach Donetick",
+ "errorDescription": "Your tasks are safe. We just could not load them right now, check your connection and try again.",
+ "errorAction": "Try again",
+ "noTasksTitle": "No tasks yet",
+ "noTasksDescription": "Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.",
+ "createTask": "Create a task",
+ "moreOptions": "More options",
+ "noMatchTitle": "No tasks match this view",
+ "noMatchSearch": "Nothing matches \"{{term}}\". Try a different search, or clear what is narrowing the list.",
+ "noMatchFilters": "You have tasks, but none of them fit the filters that are currently on.",
+ "clearSearch": "Clear search",
+ "showEveryone": "Show everyone's tasks",
+ "clearFilters": "Clear filters",
+ "projectTitle": "Nothing in {{name}} yet",
+ "projectDescription": "Tasks you add to this project show up here. Your other tasks are still where you left them.",
+ "addTaskHere": "Add a task here",
+ "seeOutsideProjects": "See tasks outside projects",
+ "noProjectTitle": "No tasks here yet",
+ "noProjectDescription": "Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.",
+ "dayFreeDescription": "This day is free. Add a task if you want something to land here.",
+ "addTask": "Add task",
+ "assignee": {
+ "assigned_to_me": "There are tasks here, but none of them are assigned to you. Switch back to everyone to see the rest.",
+ "available_for_me": "There are tasks here, but none of them are available for you to pick up. Switch back to everyone to see the rest.",
+ "assigned_to_others": "There are tasks here, but none of them are assigned to someone else. Switch back to everyone to see the rest.",
+ "assigned_to_me_tasks": "There are tasks here, but none of them are assigned to you. Switch back to everyone to see the rest.",
+ "created_by_me": "There are tasks here, but none of them are created by you. Switch back to everyone to see the rest."
+ }
}
}
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 6f175b8..6af526c 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -212,7 +212,18 @@
"accountPrefs": "Account & preferences",
"invitePeople": "Invite People",
"addMembers": "Add members to your circle",
- "sidePanelSettings": "Side Panel Settings"
+ "sidePanelSettings": "Side Panel Settings",
+ "planFree": "Free",
+ "planPlus": "Plus",
+ "planPlusExpiring": "Plus (expires soon)",
+ "switchUser": "Switch User",
+ "impersonateUser": "Impersonate User",
+ "switchToLight": "Switch to Light",
+ "switchToDark": "Switch to Dark",
+ "unlockPremium": "Unlock premium features",
+ "impersonating": "Impersonating",
+ "toggleThemeAppearance": "Toggle theme appearance",
+ "upgradeToPlus": "Upgrade to Plus"
},
"realtime": {
"newTaskTitle": "New Task Created",
@@ -328,5 +339,77 @@
"tagPets": "Pets",
"tagCooking": "Cooking"
}
+ },
+ "errorReport": {
+ "titleBug": "Report an issue",
+ "titleCrash": "Report this problem",
+ "subtitleBug": "Tell us what went wrong and we’ll attach the technical details for you.",
+ "subtitleCrash": "A sentence about what you were doing turns this into something we can actually fix.",
+ "labelBug": "What went wrong?",
+ "labelCrash": "What were you doing?",
+ "placeholderBug": "e.g. Completing a chore from the list doesn’t update the due date",
+ "placeholderCrash": "e.g. I tapped a chore in My Chores and the screen went blank",
+ "email": "Email",
+ "emailOptional": "(optional — only if you want a reply)",
+ "emailPlaceholder": "you@example.com",
+ "showWhatGetsSent": "Show what gets sent",
+ "hideWhatGetsSent": "Hide what gets sent",
+ "copyDiagnostics": "Copy diagnostics",
+ "collecting": "Collecting diagnostics…",
+ "privacyNote": "No chore names, notes or attachments are included.",
+ "send": "Send report",
+ "cancel": "Cancel",
+ "notNow": "Not now",
+ "sentTitle": "Report sent",
+ "sentBody": "Thanks! this goes straight to the people who can fix it.",
+ "reference": "Reference",
+ "copyReference": "Copy reference",
+ "done": "Done",
+ "githubTitle": "Finish on GitHub",
+ "githubBody": "Nothing has been sent. We've filled in an issue with your notes and the diagnostics — review it and post when you're happy with it.",
+ "openIssue": "Open pre-filled issue",
+ "copied": "Copied",
+ "copyInstead": "Copy details instead",
+ "close": "Close"
+ },
+ "circleSetup": {
+ "inviteTitle": "Bring your squad in",
+ "inviteSubtitle": "Everyone who joins your Circle sees the same chores, takes their own turn, and stays in sync automatically.",
+ "joinTitle": "Join a circle",
+ "joinSubtitle": "Enter the code you were given and we'll send a request to join — the circle owner just needs to approve it.",
+ "loadingCode": "Loading…",
+ "copyCodeAria": "Copy circle code",
+ "copyLinkInstead": "Copy invite link instead",
+ "continue": "Continue",
+ "joinInstead": "Join an existing circle instead",
+ "codePlaceholder": "Enter code",
+ "joinButton": "Join Circle",
+ "back": "Back",
+ "codeCopied": "Code copied to clipboard",
+ "linkCopied": "Link copied to clipboard",
+ "requestSentTitle": "Request Sent",
+ "requestSentBody": "Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.",
+ "alreadyMember": "You are already a member of this circle",
+ "joinFailed": "Failed to join circle"
+ },
+ "joinCircle": {
+ "title": "You're invited to join a circle",
+ "incompleteTitle": "Invite link is incomplete",
+ "incompleteSubtitle": "This invite link is missing a code. Ask the person who invited you to send a new link.",
+ "goToDonetick": "Go to Donetick",
+ "signedOutSubtitle": "Sign in or create a Donetick account to continue. We'll send your join request once you're signed in.",
+ "signIn": "Sign in",
+ "createAccount": "Create an account",
+ "sendingTitle": "Sending your request",
+ "sendingSubtitle": "Sending your request…",
+ "greetingSubtitle": "Hi {{name}}. Send a request to share this circle's chores with its members.",
+ "adminReviewNote": "A circle admin will review your request before you get access.",
+ "sendRequest": "Send join request",
+ "requestSentTitle": "Request sent",
+ "requestSentBody": "Your request has been sent. A circle admin will need to approve it before you can access the circle and its chores. We'll notify you when it's approved.",
+ "gotIt": "Got it",
+ "alreadyMember": "You are already a member of this circle",
+ "joinFailed": "Failed to join circle",
+ "requestError": "Could not send your join request. Please try again."
}
}
diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json
index 18847d2..02714c5 100644
--- a/public/locales/en/settings.json
+++ b/public/locales/en/settings.json
@@ -583,5 +583,31 @@
},
"realtime": {
"titleSse": "Real-time Updates (SSE)"
+ },
+ "nativeCancel": {
+ "title": "Cancel Subscription",
+ "dismiss": "Dismiss",
+ "cancelViaStore": "I'll cancel from my app store",
+ "cancelDesktop": "Cancel desktop subscription",
+ "intro": "To cancel your subscription, please follow the instructions for your platform (you should cancel through the same platform you used to subscribe).",
+ "iosHeading": "For iOS (iPhone/iPad):",
+ "iosStep1": "1. Open the Settings app on your device",
+ "iosStep2": "2. Tap your name at the top of the screen",
+ "iosStep3": "3. Tap Subscriptions",
+ "iosStep4": "4. Find and tap Donetick",
+ "iosStep5": "5. Tap Cancel Subscription",
+ "iosNote": "Note: If you subscribed through iOS and are using the web/desktop version, you must cancel through iOS Settings as described above.",
+ "androidHeading": "For Android:",
+ "androidStep1": "1. Open the Google Play Store app",
+ "androidStep2": "2. Tap the profile icon in the top right",
+ "androidStep3": "3. Tap Payments & subscriptions",
+ "androidStep4": "4. Tap Subscriptions",
+ "androidStep5": "5. Find and tap Donetick",
+ "androidStep6": "6. Tap Cancel subscription",
+ "androidNote": "Note: If you subscribed through Google Play and are using the web/desktop version, you must cancel through Google Play as described above.",
+ "webHeading": "For Web/Desktop Subscriptions:",
+ "webBody": "If you originally subscribed through our website or desktop app, you can cancel your subscription by going to the Account Settings section on our website. using a web browser",
+ "webNote": "Important: You must cancel your subscription through the same platform where you originally subscribed. If you subscribed through the iOS App Store or Google Play Store (even if you're now using the web/desktop version), you must cancel through that original platform using the instructions above.",
+ "footer": "Your subscription will remain active until the end of your current billing period."
}
}
diff --git a/public/locales/en/things.json b/public/locales/en/things.json
index 1181eb5..f54086c 100644
--- a/public/locales/en/things.json
+++ b/public/locales/en/things.json
@@ -14,5 +14,47 @@
"visualization": "Data Visualization",
"changeHistory": "Change History",
"updated": "Updated"
+ },
+ "view": {
+ "title": "Things",
+ "description": "Things are custom fields that can be attached to tasks to capture additional information. They can be of type text, number, or boolean. You can associate things with tasks and have the task due once condition is met",
+ "searchPlaceholder": "Search things",
+ "sortName": "Name",
+ "sortType": "Type",
+ "sortState": "State",
+ "sortUpdated": "Last updated",
+ "filterTitle": "Type",
+ "filterAll": "All types",
+ "emptyTitle": "No things yet",
+ "emptyDescription": "A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.",
+ "emptyAction": "Create a thing",
+ "noResultsTitle": "No things match",
+ "noResultsSearch": "No thing matches \"{{term}}\".",
+ "noResultsFilter": "No thing matches the current filter.",
+ "clearSearch": "Clear search",
+ "showAll": "Show all things",
+ "toggle": "Toggle"
+ },
+ "types": {
+ "text": "Text",
+ "number": "Number",
+ "boolean": "Boolean"
+ },
+ "notify": {
+ "savedTitle": "Saved",
+ "savedMessage": "Thing saved successfully",
+ "saveFailTitle": "Unable to save thing",
+ "queuedMessage": "You are offline and the request has been queued",
+ "saveFailMessage": "An error occurred while saving the thing",
+ "deleteTitle": "Delete Things",
+ "deleteMessage": "Are you sure you want to delete this Thing?",
+ "deleteBlockedTitle": "Unable to Delete Thing",
+ "deleteBlockedMessage": "Unable to delete thing with associated tasks",
+ "deleteFailTitle": "Unable to delete thing",
+ "deleteFailMessage": "An error occurred while deleting the thing",
+ "updatedTitle": "Updated",
+ "updatedMessage": "Thing state updated successfully",
+ "updateFailTitle": "Unable to update thing state",
+ "updateFailMessage": "An error occurred while updating the thing state"
}
}
diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs
new file mode 100644
index 0000000..7295c5f
--- /dev/null
+++ b/scripts/i18n-audit.mjs
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+/**
+ * Fails if any t() / key in src/ has no entry in public/locales/en/.
+ *
+ * Why this is a script and not an ESLint rule: the namespace for a bare
+ * t('foo') comes from the useTranslation('chores') call at the top of the
+ * component, so resolving a key needs whole-file context that a per-node lint
+ * rule does not have. Editor i18n plugins get this wrong constantly — they
+ * assume the default namespace and flag half the codebase.
+ *
+ * Usage: node scripts/i18n-audit.mjs [--orphans]
+ * --orphans also list en/*.json keys that nothing in src/ references
+ */
+
+import { readdirSync, readFileSync, statSync } from 'node:fs'
+import { basename, join } from 'node:path'
+
+const LOCALE_DIR = 'public/locales/en'
+const SRC_DIR = 'src'
+const DEFAULT_NS = 'common'
+
+const bundles = Object.fromEntries(
+ readdirSync(LOCALE_DIR)
+ .filter(f => f.endsWith('.json'))
+ .map(f => [
+ basename(f, '.json'),
+ JSON.parse(readFileSync(join(LOCALE_DIR, f), 'utf8')),
+ ]),
+)
+
+const walk = dir =>
+ readdirSync(dir).flatMap(entry => {
+ const full = join(dir, entry)
+ return statSync(full).isDirectory()
+ ? walk(full)
+ : /\.jsx?$/.test(full)
+ ? [full]
+ : []
+ })
+
+const lookup = (ns, key) =>
+ key.split('.').reduce((cur, part) => {
+ if (cur === null || typeof cur !== 'object' || !(part in cur)) return null
+ return cur[part]
+ }, bundles[ns] ?? null)
+
+/**
+ * i18next appends _one/_other to plural keys, so `card.points` is present in
+ * the bundle as `card.points_one` + `card.points_other`. Treat a leaf whose
+ * siblings carry a plural suffix as resolved.
+ */
+const exists = (ns, key) => {
+ if (lookup(ns, key) !== null) return true
+ const dot = key.lastIndexOf('.')
+ const parent = dot === -1 ? (bundles[ns] ?? null) : lookup(ns, key.slice(0, dot))
+ const leaf = key.slice(dot + 1)
+ return (
+ parent !== null &&
+ typeof parent === 'object' &&
+ Object.keys(parent).some(k => k.startsWith(`${leaf}_`))
+ )
+}
+
+const collectKeys = src => {
+ const found = new Set()
+ const push = re => {
+ for (const m of src.matchAll(re)) found.add(m[1])
+ }
+ // t('key') / t("key") / t(`key`)
+ push(/\bt\(\s*'([A-Za-z][\w.:-]*)'/g)
+ push(/\bt\(\s*"([A-Za-z][\w.:-]*)"/g)
+ push(/\bt\(\s*`([A-Za-z][\w.:-]*)`/g)
+ // t(`prefix.${expr}`) — the interpolated leaf is unknowable, but the
+ // prefix must at least resolve to an object
+ push(/\bt\(\s*`([A-Za-z][\w.:-]*)\.\$\{/g)
+ return found
+}
+
+const failures = []
+
+for (const file of walk(SRC_DIR).sort()) {
+ const src = readFileSync(file, 'utf8')
+ const nsMatch = src.match(/useTranslation\(\s*(?:'([^']+)'|\[\s*'([^']+)')/)
+ const hasTrans = src.includes('i18nKey')
+ if (!nsMatch && !hasTrans) continue
+
+ const fallbackNs = nsMatch ? (nsMatch[1] ?? nsMatch[2]) : DEFAULT_NS
+
+ const check = (raw, kind, nsOverride) => {
+ const [ns, key] = raw.includes(':')
+ ? raw.split(/:(.+)/)
+ : [nsOverride ?? fallbackNs, raw]
+ if (!exists(ns, key)) failures.push(`${file} ${kind} ${ns}:${key}`)
+ }
+
+ for (const raw of collectKeys(src)) check(raw, 't()')
+
+ // and thin wrappers that forward i18nKey
+ const transNs = src.match(/\bns='([^']+)'/)?.[1]
+ for (const m of src.matchAll(/i18nKey=(?:'([^']+)'|\{'([^']+)'\})/g)) {
+ check(m[1] ?? m[2], 'Trans', transNs)
+ }
+}
+
+if (process.argv.includes('--orphans')) {
+ const allSrc = walk(SRC_DIR)
+ .map(f => readFileSync(f, 'utf8'))
+ .join('\n')
+ const flatten = (obj, prefix = '') =>
+ Object.entries(obj).flatMap(([k, v]) =>
+ v !== null && typeof v === 'object'
+ ? flatten(v, `${prefix}${k}.`)
+ : [`${prefix}${k}`],
+ )
+ const orphans = []
+ for (const [ns, bundle] of Object.entries(bundles)) {
+ for (const key of flatten(bundle)) {
+ const leaf = key.replace(/_(one|other|zero|two|few|many)$/, '')
+ if (!allSrc.includes(leaf) && !allSrc.includes(leaf.split('.').pop())) {
+ orphans.push(`${ns}:${leaf}`)
+ }
+ }
+ }
+ if (orphans.length) {
+ console.log(`\nPossibly unused (${new Set(orphans).size}):`)
+ console.log([...new Set(orphans)].sort().join('\n'))
+ }
+}
+
+const unique = [...new Set(failures)].sort()
+if (unique.length) {
+ console.error(`i18n audit: ${unique.length} unresolved key(s)\n`)
+ console.error(unique.join('\n'))
+ process.exit(1)
+}
+console.log('i18n audit: all keys resolve')
diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx
index 1bacdfa..f2bad91 100644
--- a/src/components/UserProfileAvatar.jsx
+++ b/src/components/UserProfileAvatar.jsx
@@ -63,20 +63,20 @@ const UserProfileAvatar = () => {
const isPlusUser = isPlusAccount(userProfile)
const getSubscriptionStatus = () => {
- if (!userProfile) return 'Free'
+ if (!userProfile) return t('userMenu.planFree')
if (userProfile.subscription === 'active') {
- return 'Plus'
+ return t('userMenu.planPlus')
}
if (
userProfile.subscription === 'cancelled' &&
moment().isBefore(userProfile.expiration)
) {
- return 'Plus (expires soon)'
+ return t('userMenu.planPlusExpiring')
}
- return 'Free'
+ return t('userMenu.planFree')
}
const handleLogout = () => {
@@ -270,7 +270,7 @@ const UserProfileAvatar = () => {
fontWeight: 500,
}}
>
- Impersonating
+ {t('userMenu.impersonating')}
)}
@@ -296,7 +296,9 @@ const UserProfileAvatar = () => {
- {isImpersonating ? 'Switch User' : 'Impersonate User'}
+ {isImpersonating
+ ? t('userMenu.switchUser')
+ : t('userMenu.impersonateUser')}
{
- {isDarkMode ? 'Switch to Light' : 'Switch to Dark'}
+ {isDarkMode
+ ? t('userMenu.switchToLight')
+ : t('userMenu.switchToDark')}
- Toggle theme appearance
+ {t('userMenu.toggleThemeAppearance')}
@@ -465,9 +469,11 @@ const UserProfileAvatar = () => {
fontWeight: 500,
}}
>
- Upgrade to Plus
+ {t('userMenu.upgradeToPlus')}
+
+
+ {t('userMenu.unlockPremium')}
- Unlock premium features
)}
@@ -516,7 +522,7 @@ const UserProfileAvatar = () => {
level='body-sm'
sx={{ fontWeight: 500, color: 'var(--joy-palette-danger-500)' }}
>
- Logout
+ {t('logout')}
diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx
index ed030fd..dacaea5 100644
--- a/src/views/ChoreEdit/RepeatSection.jsx
+++ b/src/views/ChoreEdit/RepeatSection.jsx
@@ -18,6 +18,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries'
@@ -33,11 +34,7 @@ const FREQUENCY_TYPES_RADIOS = [
'custom',
]
-const FREQUENCY_TYPE_MESSAGE = {
- adaptive:
- 'This chore will be scheduled dynamically based on previous completion dates.',
- custom: 'This chore will be scheduled based on a custom frequency.',
-}
+const FREQUENCY_TYPE_WITH_MESSAGE = ['adaptive', 'custom']
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
const MONTHS = [
'january',
@@ -64,46 +61,40 @@ const DAYS = [
'sunday',
]
-const WEEK_PATTERNS = {
- every_week: 'Every week',
- week_of_month: 'Specific occurrences in the month',
-}
+const WEEK_PATTERNS = ['every_week', 'week_of_month']
+
+const DAY_OCCURRENCE_VALUES = [1, 2, 3, 4, -1]
+
+// -1 is the "last occurrence" sentinel; everything else keys off its number.
+const occurrenceKey = value => (value === -1 ? 'last' : String(value))
-const DAY_OCCURRENCE_OPTIONS = [
- { value: 1, label: '1st occurrence' },
- { value: 2, label: '2nd occurrence' },
- { value: 3, label: '3rd occurrence' },
- { value: 4, label: '4th occurrence' },
- { value: -1, label: 'Last occurrence' },
-]
// Helper function to generate schedule preview text
-const generateSchedulePreview = (metadata, formatTimeFn) => {
+const generateSchedulePreview = (metadata, formatTimeFn, t) => {
if (!metadata?.days?.length) return ''
const dayNames = metadata.days
- .map(day => day.charAt(0).toUpperCase() + day.slice(1, 3))
+ .map(day => t(`repeat.daysShort.${day}`))
.join(', ')
- const timeStr = metadata.time ? formatTimeFn(metadata.time) : '6:00 PM'
-
- if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
- return `Every ${dayNames} at ${timeStr}`
- }
+ const timeStr = metadata.time
+ ? formatTimeFn(metadata.time)
+ : t('repeat.defaultTime')
if (
metadata.weekPattern === 'week_of_month' &&
metadata.occurrences?.length
) {
const occurrenceStr = metadata.occurrences
- .map(w => {
- if (w === -1) return 'last'
- return `${w}${w === 1 ? 'st' : w === 2 ? 'nd' : w === 3 ? 'rd' : 'th'}`
- })
+ .map(w => t(`repeat.ordinal.${occurrenceKey(w)}`))
.join(', ')
- return `Every ${occurrenceStr} ${dayNames} of the month at ${timeStr}`
+ return t('repeat.previewOccurrence', {
+ occurrences: occurrenceStr,
+ days: dayNames,
+ time: timeStr,
+ })
}
- return `Every ${dayNames} at ${timeStr}`
+ return t('repeat.previewEvery', { days: dayNames, time: timeStr })
}
export const RepeatOnSections = ({
@@ -114,6 +105,7 @@ export const RepeatOnSections = ({
onFrequencyUpdate,
}) => {
const { fmt } = useLocalization()
+ const { t } = useTranslation('chores')
// 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(() => {
@@ -142,7 +134,7 @@ export const RepeatOnSections = ({
flexDirection: 'column',
}}
>
- Time of day:
+ {t('repeat.timeOfDay')}
- Every:
+ {t('repeat.every')}
@@ -239,7 +231,7 @@ export const RepeatOnSections = ({
overlay
disableIcon
variant='soft'
- label={item.charAt(0).toUpperCase() + item.slice(1)}
+ label={t(`repeat.days.${item}`)}
/>
))}
@@ -268,8 +260,8 @@ export const RepeatOnSections = ({
disableIcon
>
{frequencyMetadata?.days?.length === 7
- ? 'Unselect All'
- : 'Select All'}
+ ? t('repeat.unselectAll')
+ : t('repeat.selectAll')}
@@ -291,20 +283,16 @@ export const RepeatOnSections = ({
}}
sx={{ gap: 1, '& > div': { p: 1 } }}
>
- {Object.entries(WEEK_PATTERNS).map(([value, label]) => (
+ {WEEK_PATTERNS.map(value => (
-
- {value === 'every_week' && (
-
- Task repeats every week on selected days
-
- )}
- {value === 'week_of_month' && (
-
- Task repeats on specific day occurrences each month
- (e.g., 1st Monday, 3rd Friday)
-
- )}
+
+
+ {t(`repeat.weekPatternHelp.${value}`)}
+
))}
@@ -312,10 +300,10 @@ export const RepeatOnSections = ({
{frequencyMetadata?.weekPattern === 'week_of_month' && (
- Select which occurrences of the selected days:
+ {t('repeat.selectOccurrences')}
- Example: "1st Monday" means the first Monday of each month
+ {t('repeat.occurrenceExample')}
- {DAY_OCCURRENCE_OPTIONS.map(option => (
-
+ {DAY_OCCURRENCE_VALUES.map(option => (
+
{
const currentOccurrences =
frequencyMetadata?.occurrences || []
const newOccurrences =
- currentOccurrences.includes(option.value)
- ? currentOccurrences.filter(
- w => w !== option.value,
- )
- : [...currentOccurrences, option.value]
+ currentOccurrences.includes(option)
+ ? currentOccurrences.filter(w => w !== option)
+ : [...currentOccurrences, option]
onFrequencyMetadataUpdate({
...frequencyMetadata,
occurrences: newOccurrences.sort((a, b) => {
@@ -355,7 +341,9 @@ export const RepeatOnSections = ({
overlay
disableIcon
variant='soft'
- label={option.label}
+ label={t(
+ `repeat.occurrence.${occurrenceKey(option)}`,
+ )}
/>
))}
@@ -367,7 +355,7 @@ export const RepeatOnSections = ({
onClick={() => {
if (
frequencyMetadata?.occurrences?.length ===
- DAY_OCCURRENCE_OPTIONS.length
+ DAY_OCCURRENCE_VALUES.length
) {
onFrequencyMetadataUpdate({
...frequencyMetadata,
@@ -376,9 +364,7 @@ export const RepeatOnSections = ({
} else {
onFrequencyMetadataUpdate({
...frequencyMetadata,
- occurrences: DAY_OCCURRENCE_OPTIONS.map(
- option => option.value,
- ),
+ occurrences: [...DAY_OCCURRENCE_VALUES],
})
}
}}
@@ -386,9 +372,9 @@ export const RepeatOnSections = ({
disableIcon
>
{frequencyMetadata?.occurrences?.length ===
- DAY_OCCURRENCE_OPTIONS.length
- ? 'Unselect All'
- : 'Select All'}
+ DAY_OCCURRENCE_VALUES.length
+ ? t('repeat.unselectAll')
+ : t('repeat.selectAll')}
@@ -400,7 +386,7 @@ export const RepeatOnSections = ({
{frequencyMetadata?.days?.length > 0 && (
- {generateSchedulePreview(frequencyMetadata, fmt.time)}
+ {generateSchedulePreview(frequencyMetadata, fmt.time, t)}
)}
@@ -458,7 +444,7 @@ export const RepeatOnSections = ({
overlay
disableIcon
variant='soft'
- label={item.charAt(0).toUpperCase() + item.slice(1)}
+ label={t(`repeat.months.${item}`)}
/>
))}
@@ -485,8 +471,8 @@ export const RepeatOnSections = ({
disableIcon
>
{frequencyMetadata?.months?.length === 12
- ? 'Unselect All'
- : 'Select All'}
+ ? t('repeat.unselectAll')
+ : t('repeat.selectAll')}
@@ -497,7 +483,7 @@ export const RepeatOnSections = ({
mb: 1.5,
}}
>
- on the
+ {t('repeat.onThe')}
- of the above month/s
+ {t('repeat.ofAboveMonths')}
{timePickerComponent}
>
@@ -540,10 +526,11 @@ const RepeatSection = ({
viewOnly = false,
}) => {
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
+ const { t } = useTranslation('chores')
return (
- Repeat:
+ {t('repeat.title')}
{
@@ -556,16 +543,14 @@ const RepeatSection = ({
checked={!['once', 'trigger'].includes(frequencyType)}
value={!['once', 'trigger'].includes(frequencyType)}
overlay
- label='Repeat this task'
+ label={t('repeat.repeatThisTask')}
/>
-
- Is this something needed to be done regularly?
-
+ {t('repeat.repeatThisTaskHelp')}
{!['once', 'trigger'].includes(frequencyType) && (
<>
- How often should it be repeated?
+ {t('repeat.howOften')}
))}
- {FREQUENCY_TYPE_MESSAGE[frequencyType]}
+
+ {FREQUENCY_TYPE_WITH_MESSAGE.includes(frequencyType)
+ ? t(`repeat.freqMessage.${frequencyType}`)
+ : ''}
+
{frequencyType === 'custom' ||
(REPEAT_ON_TYPE.includes(frequencyType) && (
<>
- Repeat on:
+ {t('repeat.repeatOn')}
@@ -678,21 +664,7 @@ const RepeatSection = ({
}}
value={item}
disableIcon
- label={item
- .split('_')
- .map((i, idx) => {
- // first or last word
- if (
- idx === 0 ||
- idx === item.split('_').length - 1
- ) {
- return (
- i.charAt(0).toUpperCase() + i.slice(1)
- )
- }
- return i
- })
- .join(' ')}
+ label={t(`repeat.repeatOnType.${item}`)}
variant='plain'
sx={{
px: 2,
@@ -749,24 +721,23 @@ const RepeatSection = ({
value={frequencyType === 'trigger'}
disabled={!isPlusAccount(userProfile)}
overlay
- label='Trigger this task based on a thing state'
+ label={t('repeat.triggerLabel')}
/>
- Is this something that should be done when a thing state changes?{' '}
+ {t('repeat.triggerHelp')}{' '}
{userProfile && !isPlusAccount(userProfile) && (
- Plus Feature
+ {t('settings:common.plusFeature')}
)}
{!isPlusAccount(userProfile) && (
- Thing-based triggers are not available in the Basic plan. Upgrade to
- Plus to automatically trigger tasks when device states change.
+ {t('repeat.triggerPlanWarning')}
)}
diff --git a/src/views/Chores/ChoreListView.jsx b/src/views/Chores/ChoreListView.jsx
index 968d160..1f399ed 100644
--- a/src/views/Chores/ChoreListView.jsx
+++ b/src/views/Chores/ChoreListView.jsx
@@ -152,7 +152,7 @@ const ChoreListView = ({
>
- Reject
+ {t('list.reject')}
@@ -173,7 +173,7 @@ const ChoreListView = ({
>
- Pending
+ {t('list.pending')}
@@ -231,7 +231,7 @@ const ChoreListView = ({
>
- Schedule
+ {t('list.schedule')}
@@ -251,7 +251,7 @@ const ChoreListView = ({
>
- Edit
+ {t('common:edit')}
@@ -272,7 +272,7 @@ const ChoreListView = ({
>
- Nudge
+ {t('list.nudge')}
@@ -293,7 +293,7 @@ const ChoreListView = ({
>
- Delete
+ {t('common:delete')}
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index 36f318d..61117b1 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -77,13 +77,17 @@ import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
// Mirrors the assignee options in the toolbar, phrased to drop into a
// sentence ("none of them are assigned to you").
-const ASSIGNEE_FILTER_LABELS = {
- assigned_to_me: 'assigned to you',
- available_for_me: 'available for you to pick up',
- assigned_to_others: 'assigned to someone else',
- assigned_to_me_tasks: 'assigned to you',
- created_by_me: 'created by you',
-}
+// Which assignee filters describe a subset of "everyone" — the empty-state
+// copy for each lives under `chores:empty.assignee.*` as a whole sentence,
+// since the fragment ("assigned to you") cannot be slotted mid-sentence in
+// every language.
+const ASSIGNEE_FILTER_KEYS = [
+ 'assigned_to_me',
+ 'available_for_me',
+ 'assigned_to_others',
+ 'assigned_to_me_tasks',
+ 'created_by_me',
+]
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
@@ -899,7 +903,8 @@ const MyChores = () => {
// The assignee filter ("Mine", "Available to me", ...) is applied inside
// ChoresGrouper, not in projectFilteredChores, so it can hide every task
// while the unfiltered list still looks full. It narrows like any other.
- const assigneeFilterLabel = ASSIGNEE_FILTER_LABELS[selectedChoreFilter]
+ const hasAssigneeNarrowing =
+ ASSIGNEE_FILTER_KEYS.includes(selectedChoreFilter)
const hasAssigneeFilter = Boolean(
selectedChoreFilter && selectedChoreFilter !== 'anyone',
)
@@ -919,7 +924,7 @@ const MyChores = () => {
// Worth its own wording: the assignee filter is the one narrowing that is
// easy to forget you left on, so name it rather than saying "filters".
const isAssigneeOnlyNarrowing = Boolean(
- assigneeFilterLabel &&
+ hasAssigneeNarrowing &&
!searchTerm?.length &&
!hasQuickFilters &&
!activeFilterId,
@@ -973,13 +978,12 @@ const MyChores = () => {
variant='error'
fullHeight
icon={}
- title={"Can't reach Donetick"}
+ title={t('empty.errorTitle')}
description={
- choresErrorDetails?.message ||
- 'Your tasks are safe. We just could not load them right now, check your connection and try again.'
+ choresErrorDetails?.message || t('empty.errorDescription')
}
primaryAction={{
- label: 'Try again',
+ label: t('empty.errorAction'),
onClick: () => {
refetchChores()
queryClient.invalidateQueries(['circleMembers'])
@@ -1163,15 +1167,15 @@ const MyChores = () => {
variant='empty'
fullHeight
icon={}
- title='No tasks yet'
- description='Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.'
+ title={t('empty.noTasksTitle')}
+ description={t('empty.noTasksDescription')}
primaryAction={{
- label: 'Create a task',
+ label: t('empty.createTask'),
startDecorator: ,
onClick: () => setAddTaskModalOpen(true),
}}
secondaryAction={{
- label: 'More options',
+ label: t('empty.moreOptions'),
onClick: () => Navigate('/chores/create'),
}}
/>
@@ -1183,21 +1187,21 @@ const MyChores = () => {
variant='no-results'
fullHeight
icon={}
- title='No tasks match this view'
+ title={t('empty.noMatchTitle')}
description={
searchTerm?.length > 0
- ? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
+ ? t('empty.noMatchSearch', { term: searchTerm })
: isAssigneeOnlyNarrowing
- ? `There are tasks here, but none of them are ${assigneeFilterLabel}. Switch back to everyone to see the rest.`
- : 'You have tasks, but none of them fit the filters that are currently on.'
+ ? t(`empty.assignee.${selectedChoreFilter}`)
+ : t('empty.noMatchFilters')
}
primaryAction={{
label:
searchTerm?.length > 0
- ? 'Clear search'
+ ? t('empty.clearSearch')
: isAssigneeOnlyNarrowing
- ? "Show everyone's tasks"
- : 'Clear filters',
+ ? t('empty.showEveryone')
+ : t('empty.clearFilters'),
onClick: clearNarrowing,
}}
/>
@@ -1206,15 +1210,15 @@ const MyChores = () => {
variant='empty'
fullHeight
icon={}
- title={`Nothing in ${selectedProject.name} yet`}
- description='Tasks you add to this project show up here. Your other tasks are still where you left them.'
+ title={t('empty.projectTitle', { name: selectedProject.name })}
+ description={t('empty.projectDescription')}
primaryAction={{
- label: 'Add a task here',
+ label: t('empty.addTaskHere'),
startDecorator: ,
onClick: () => setAddTaskModalOpen(true),
}}
secondaryAction={{
- label: 'See tasks outside projects',
+ label: t('empty.seeOutsideProjects'),
onClick: () => setSelectedProjectWithCache(null),
}}
/>
@@ -1223,10 +1227,10 @@ const MyChores = () => {
variant='empty'
fullHeight
icon={}
- title='No tasks here yet'
- description='Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.'
+ title={t('empty.noProjectTitle')}
+ description={t('empty.noProjectDescription')}
primaryAction={{
- label: 'Create a task',
+ label: t('empty.createTask'),
startDecorator: ,
onClick: () => setAddTaskModalOpen(true),
}}
@@ -1404,9 +1408,9 @@ const MyChores = () => {
size='sm'
icon={}
title={t('list.nothingScheduled')}
- description='This day is free. Add a task if you want something to land here.'
+ description={t('empty.dayFreeDescription')}
primaryAction={{
- label: 'Add task',
+ label: t('empty.addTask'),
startDecorator: ,
onClick: () => setAddTaskModalOpen(true),
}}
diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx
index ea8e995..65d1d7e 100644
--- a/src/views/Chores/components/ChoreToolbarPrototype.jsx
+++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx
@@ -252,23 +252,27 @@ const ChoreToolbar = ({
if (!condition?.type) return 'Filter'
const typeLabels = {
- assignee: 'Assignee',
- createdBy: 'Created By',
- status: 'Status',
- priority: 'Priority',
+ assignee: t('filterBuilder.assignee'),
+ createdBy: t('filterBuilder.createdBy'),
+ status: t('filterBuilder.statusHeading'),
+ priority: t('filterBuilder.priority'),
label: t('labels.label'),
- project: 'Project',
- dueDate: 'Due Date',
- points: 'Points',
+ project: t('filterBuilder.project'),
+ dueDate: t('filterBuilder.dueDate'),
+ points: t('filterBuilder.points'),
}
- const typeLabel = typeLabels[condition.type] || 'Filter'
- const prefix = condition.operator === 'isNot' ? 'Not ' : ''
+ const typeLabel =
+ typeLabels[condition.type] || t('filterBuilder.filterFallback')
+ const prefix =
+ condition.operator === 'isNot' ? t('filterBuilder.notPrefix') : ''
if (condition.type === 'dueDate') {
- const dueDateLabel =
- DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
- 'Custom'
+ const dueDateLabel = DUE_DATE_OPTIONS.some(
+ o => o.value === condition.operator,
+ )
+ ? t(`filterBuilder.dueDateOption.${condition.operator}`)
+ : t('filterBuilder.customDueDate')
return `${typeLabel}: ${dueDateLabel}`
}
@@ -292,9 +296,9 @@ const ChoreToolbar = ({
return member?.displayName || member?.username || String(value)
}
if (condition.type === 'status') {
- return (
- CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
- )
+ return CHORE_STATUSES.some(s => s.value === value)
+ ? t(`filterBuilder.status.${value}`)
+ : String(value)
}
if (condition.type === 'priority') {
return Priorities.find(p => p.value === value)?.name || String(value)
diff --git a/src/views/Chores/components/FilterBuilderContent.jsx b/src/views/Chores/components/FilterBuilderContent.jsx
index a973032..348a47b 100644
--- a/src/views/Chores/components/FilterBuilderContent.jsx
+++ b/src/views/Chores/components/FilterBuilderContent.jsx
@@ -9,17 +9,20 @@ import {
TaskAlt,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
+import { useTranslation } from 'react-i18next'
import Priorities from '../../../utils/Priorities'
+// `labelKey` resolves against `chores:filterBuilder.dueDateOption.*` — these
+// are also read by ChoreToolbarPrototype, which translates them the same way.
export const DUE_DATE_OPTIONS = [
- { value: 'isOverdue', label: 'Overdue', color: 'danger' },
- { value: 'isDueToday', label: 'Today', color: 'warning' },
- { value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
- { value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
- { value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
- { value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
- { value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
+ { value: 'isOverdue', color: 'danger' },
+ { value: 'isDueToday', color: 'warning' },
+ { value: 'isDueTomorrow', color: 'primary' },
+ { value: 'isDueThisWeek', color: 'primary' },
+ { value: 'isDueThisMonth', color: 'neutral' },
+ { value: 'hasNoDueDate', color: 'neutral' },
+ { value: 'hasDueDate', color: 'neutral' },
]
export const POINTS_OPERATORS = [
@@ -31,10 +34,10 @@ export const POINTS_OPERATORS = [
]
export const CHORE_STATUSES = [
- { value: 0, label: 'Active' },
- { value: 1, label: 'Started' },
- { value: 2, label: 'In Progress' },
- { value: 3, label: 'Pending Approval' },
+ { value: 0 },
+ { value: 1 },
+ { value: 2 },
+ { value: 3 },
]
export const defaultSelections = () => ({
@@ -119,35 +122,39 @@ const SectionHeader = ({ children, icon, label }) => (
)
-const IncludeExcludeToggle = ({
- labels = ['Include', 'Exclude'],
- onChange,
- value,
-}) => (
-
- {[
- { op: 'is', label: labels[0] },
- { op: 'isNot', label: labels[1] },
- ].map(o => (
- onChange(o.op)}
- sx={{
- cursor: 'pointer',
- userSelect: 'none',
- transition: 'all 0.15s ease',
- }}
- >
- {o.label}
-
- ))}
-
-)
+const IncludeExcludeToggle = ({ labelKeys, onChange, value }) => {
+ const { t } = useTranslation('chores')
+ const [includeKey, excludeKey] = labelKeys ?? ['include', 'exclude']
+ return (
+
+ {[
+ { op: 'is', labelKey: includeKey },
+ { op: 'isNot', labelKey: excludeKey },
+ ].map(o => (
+ onChange(o.op)}
+ sx={{
+ cursor: 'pointer',
+ userSelect: 'none',
+ transition: 'all 0.15s ease',
+ }}
+ >
+ {t(`filterBuilder.${o.labelKey}`)}
+
+ ))}
+
+ )
+}
/**
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
@@ -163,6 +170,7 @@ const FilterBuilderContent = ({
projects = [],
selections,
}) => {
+ const { t } = useTranslation('chores')
const toggleValue = (type, value) =>
onSelectionsChange(prev => {
const cur = prev[type].values || []
@@ -272,7 +280,7 @@ const FilterBuilderContent = ({
{/* Assignee */}
{members.length > 0 && (
<>
- } label='Assignee'>
+ } label={t('filterBuilder.assignee')}>
setOperator('assignee', op)}
@@ -286,7 +294,7 @@ const FilterBuilderContent = ({
{/* Created By */}
{members.length > 0 && (
<>
- } label='Created By'>
+ } label={t('filterBuilder.createdBy')}>
setOperator('createdBy', op)}
@@ -298,17 +306,29 @@ const FilterBuilderContent = ({
)}
{/* Status */}
- } label='Status'>
+ }
+ label={t('filterBuilder.statusHeading')}
+ >
setOperator('status', op)}
/>
- {chipRow('status', CHORE_STATUSES)}
+ {chipRow(
+ 'status',
+ CHORE_STATUSES.map(st => ({
+ value: st.value,
+ label: t(`filterBuilder.status.${st.value}`),
+ })),
+ )}
{/* Priority */}
- } label='Priority'>
+ }
+ label={t('filterBuilder.priority')}
+ >
setOperator('priority', op)}
@@ -329,7 +349,10 @@ const FilterBuilderContent = ({
{/* Due Date */}
- } label='Due Date' />
+ }
+ label={t('filterBuilder.dueDate')}
+ />
{DUE_DATE_OPTIONS.map(opt => {
const isSelected = selections.dueDate.operator === opt.value
@@ -348,7 +371,7 @@ const FilterBuilderContent = ({
transition: 'all 0.15s ease',
}}
>
- {opt.label}
+ {t(`filterBuilder.dueDateOption.${opt.value}`)}
)
})}
@@ -358,11 +381,11 @@ const FilterBuilderContent = ({
{/* Labels */}
{labels.length > 0 && (
<>
- } label='Labels'>
+ } label={t('filterBuilder.labels')}>
setOperator('label', op)}
- labels={['Has', "Doesn't Have"]}
+ labelKeys={['has', 'doesntHave']}
/>
@@ -410,14 +433,17 @@ const FilterBuilderContent = ({
{/* Projects */}
{projects.length > 0 && (
<>
- } label='Projects'>
+ }
+ label={t('filterBuilder.projects')}
+ >
setOperator('project', op)}
/>
{chipRow('project', [
- { value: 'default', label: 'Default Project' },
+ { value: 'default', label: t('filterBuilder.defaultProject') },
...projects
.filter(p => p.id !== 'default')
.map(p => ({ value: p.id, label: p.name })),
@@ -427,7 +453,7 @@ const FilterBuilderContent = ({
)}
{/* Points */}
- } label='Points' />
+ } label={t('filterBuilder.points')} />
@@ -479,7 +505,7 @@ const FilterBuilderContent = ({
}
sx={{ cursor: 'pointer' }}
>
- Clear
+ {t('filterBuilder.clear')}
)}
diff --git a/src/views/Chores/components/MultiSelectToolbar.jsx b/src/views/Chores/components/MultiSelectToolbar.jsx
index 1f7dbad..2d7b2c1 100644
--- a/src/views/Chores/components/MultiSelectToolbar.jsx
+++ b/src/views/Chores/components/MultiSelectToolbar.jsx
@@ -78,19 +78,19 @@ const dateOnly = date => ({
const DUE_DATE_PRESETS = [
{
key: 'today',
- label: 'Today',
+ labelKey: 'presetToday',
resolve: () => dateOnly(moment()),
hint: () => moment().format('ddd, MMM D'),
},
{
key: 'tomorrow',
- label: 'Tomorrow',
+ labelKey: 'presetTomorrow',
resolve: () => dateOnly(moment().add(1, 'day')),
hint: () => moment().add(1, 'day').format('ddd, MMM D'),
},
{
key: 'next-week',
- label: 'Next week',
+ labelKey: 'presetNextWeek',
resolve: () => dateOnly(moment().add(1, 'week').startOf('isoWeek')),
hint: () => moment().add(1, 'week').startOf('isoWeek').format('ddd, MMM D'),
},
@@ -189,18 +189,18 @@ const MultiSelectToolbar = ({
}
const dueDateValue = summary.dueDate?.isMixed
- ? 'Mixed'
+ ? t('multiToolbar.mixed')
: summary.dueDate?.value
? moment(summary.dueDate.value).format('MMM D')
: null
const priorityValue = summary.priority?.isMixed
- ? 'Mixed'
+ ? t('multiToolbar.mixed')
: Priorities.find(p => p.value === summary.priority?.value)?.name.trim() ||
null
const assigneeValue = summary.assignee?.isMixed
- ? 'Mixed'
+ ? t('multiToolbar.mixed')
: members.find(m => m.userId === summary.assignee?.value)?.displayName ||
null
@@ -389,11 +389,11 @@ const MultiSelectToolbar = ({
}}
title={
dueDateValue
- ? `Due date on selected tasks: ${dueDateValue}`
- : 'Set due date on selected tasks'
+ ? t('multiToolbar.dueTitleSet', { value: dueDateValue })
+ : t('multiToolbar.dueTitleEmpty')
}
>
- Due
+ {t('multiToolbar.due')}
- {preset.label}
+
+ {t(`multiToolbar.${preset.labelKey}`)}
+
- Pick date…
+
+ {t('multiToolbar.pickDate')}
+
- No due date
+
+ {t('multiToolbar.noDueDate')}
+
@@ -500,9 +506,9 @@ const MultiSelectToolbar = ({
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
- title='Move selected tasks to a project'
+ title={t('multiToolbar.moveTitle')}
>
- Move
+ {t('multiToolbar.move')}
@@ -634,7 +645,7 @@ const MultiSelectToolbar = ({
onClick={() => setMoreOpen(false)}
sx={{ minWidth: 140 }}
>
- Done
+ {t('multiToolbar.done')}
}
>
@@ -646,7 +657,7 @@ const MultiSelectToolbar = ({
<>
}
- label='Priority'
+ label={t('multiToolbar.priority')}
value={priorityValue}
/>
@@ -677,7 +688,7 @@ const MultiSelectToolbar = ({
onClick={runAndClose(() => onSetPriority(0))}
sx={selectableChipSx}
>
- None
+ {t('multiToolbar.noPriority')}
>
@@ -688,7 +699,7 @@ const MultiSelectToolbar = ({
}
- label='Assignee'
+ label={t('multiToolbar.assignee')}
value={assigneeValue}
/>
@@ -731,8 +742,8 @@ const MultiSelectToolbar = ({
only some of the selection has it — tapping completes the set. */}
}
- label='Labels'
- value='Tap to add · tap again to remove'
+ label={t('multiToolbar.labels')}
+ value={t('multiToolbar.labelsHint')}
/>
{labels.map(label => {
diff --git a/src/views/Circles/JoinCircle.jsx b/src/views/Circles/JoinCircle.jsx
index a62b99f..8e1d306 100644
--- a/src/views/Circles/JoinCircle.jsx
+++ b/src/views/Circles/JoinCircle.jsx
@@ -1,5 +1,6 @@
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
@@ -23,6 +24,7 @@ const enter = (delay = 0) => ({
})
const JoinCircleView = () => {
+ const { t } = useTranslation()
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
// Read the token rather than useAuth(): the provider's copy only updates
// through its own login(), so signup and the OAuth callback — which save
@@ -49,20 +51,18 @@ const JoinCircleView = () => {
clearPendingInvite()
if (resp.ok) {
showAcknowledgment(
- 'Your request has been sent. A circle admin will need to approve ' +
- "it before you can access the circle and its chores. We'll " +
- "notify you when it's approved.",
- 'Request sent',
+ t('joinCircle.requestSentBody'),
+ t('joinCircle.requestSentTitle'),
() => navigate('/chores'),
- 'Got it',
+ t('joinCircle.gotIt'),
'success',
)
} else {
setIsJoining(false)
if (resp.status === 409) {
- showError('You are already a member of this circle')
+ showError(t('joinCircle.alreadyMember'))
} else {
- showError('Failed to join circle')
+ showError(t('joinCircle.joinFailed'))
}
navigate('/chores')
}
@@ -70,9 +70,9 @@ const JoinCircleView = () => {
.catch(() => {
setIsJoining(false)
clearPendingInvite()
- showError('Could not send your join request. Please try again.')
+ showError(t('joinCircle.requestError'))
})
- }, [code, navigate, showAcknowledgment, showError])
+ }, [code, navigate, showAcknowledgment, showError, t])
// Coming back from login/signup the user already said yes by opening the
// link, so send the request instead of asking a second time. This step used
@@ -102,14 +102,13 @@ const JoinCircleView = () => {
/>
)
- let title = "You're invited to join a circle"
+ let title = t('joinCircle.title')
let subtitle = null
let body = null
if (!code) {
- title = 'Invite link is incomplete'
- subtitle =
- 'This invite link is missing a code. Ask the person who invited you to send a new link.'
+ title = t('joinCircle.incompleteTitle')
+ subtitle = t('joinCircle.incompleteSubtitle')
body = (
)
// A token that no longer resolves to a profile is as good as signed out —
// better to offer sign-in than to spin forever.
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
- subtitle =
- "Sign in or create a Donetick account to continue. We'll send your join request once you're signed in."
+ subtitle = t('joinCircle.signedOutSubtitle')
body = (
{inviteCodeField}
@@ -134,7 +132,7 @@ const JoinCircleView = () => {
sx={authButtonSx}
onClick={() => goToAuth('/login')}
>
- Sign in
+ {t('joinCircle.signIn')}
)
} else if (isProfileLoading || isJoining) {
- title = 'Sending your request'
- subtitle = 'Sending your request…'
+ title = t('joinCircle.sendingTitle')
+ subtitle = t('joinCircle.sendingSubtitle')
body = (
)
} else {
- subtitle =
- `Hi ${userProfile?.displayName || userProfile?.username}. ` +
- "Send a request to share this circle's chores with its members."
+ subtitle = t('joinCircle.greetingSubtitle', {
+ name: userProfile?.displayName || userProfile?.username,
+ })
body = (
- A circle admin will review your request before you get access.
+ {t('joinCircle.adminReviewNote')}
)
diff --git a/src/views/Modals/ErrorReportModal.jsx b/src/views/Modals/ErrorReportModal.jsx
index ca8caba..d147905 100644
--- a/src/views/Modals/ErrorReportModal.jsx
+++ b/src/views/Modals/ErrorReportModal.jsx
@@ -19,6 +19,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import {
@@ -106,6 +107,7 @@ const IconHalo = ({ color = 'primary', icon }) => (
*/
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
const { ResponsiveModal } = useResponsiveModal()
+ const { t } = useTranslation('common')
const isBugReport = !error
const [report, setReport] = useState(null)
@@ -175,21 +177,25 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='h4'
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
>
- {isBugReport ? 'Report an issue' : 'Report this problem'}
+ {isBugReport
+ ? t('errorReport.titleBug')
+ : t('errorReport.titleCrash')}
{isBugReport
- ? 'Tell us what went wrong and we’ll attach the technical details for you.'
- : 'A sentence about what you were doing turns this into something we can actually fix.'}
+ ? t('errorReport.subtitleBug')
+ : t('errorReport.subtitleCrash')}
- {isBugReport ? 'What went wrong?' : 'What were you doing?'}
+ {isBugReport
+ ? t('errorReport.labelBug')
+ : t('errorReport.labelCrash')}
- Email{' '}
+ {t('errorReport.email')}{' '}
- (optional — only if you want a reply)
+ {t('errorReport.emailOptional')}
setEmail(e.target.value)}
- placeholder='you@example.com'
+ placeholder={t('errorReport.emailPlaceholder')}
/>
@@ -236,7 +242,9 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
}
sx={{ px: 0 }}
>
- {showDetails ? 'Hide' : 'Show'} what gets sent
+ {showDetails
+ ? t('errorReport.hideWhatGetsSent')
+ : t('errorReport.showWhatGetsSent')}
{showDetails && (
@@ -258,7 +266,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
color={copied ? 'success' : 'neutral'}
onClick={copyDetails}
sx={{ position: 'absolute', top: 6, right: 6, minWidth: 0 }}
- aria-label='Copy diagnostics'
+ aria-label={t('errorReport.copyDiagnostics')}
>
{copied ? (
@@ -276,7 +284,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
color: 'text.secondary',
}}
>
- {reportText || 'Collecting diagnostics…'}
+ {reportText || t('errorReport.collecting')}
)}
@@ -284,7 +292,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='body-xs'
sx={{ color: 'text.tertiary', mt: 1, display: 'block' }}
>
- No chore names, notes or attachments are included.
+ {t('errorReport.privacyNote')}
@@ -298,7 +306,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
disabled={!report || (isBugReport && !description.trim())}
onClick={handleSubmit}
>
- Send report
+ {t('errorReport.send')}
{
underline='hover'
onClick={onClose}
>
- {isBugReport ? 'Cancel' : 'Not now'}
+ {isBugReport
+ ? t('errorReport.cancel')
+ : t('errorReport.notNow')}
@@ -323,13 +333,13 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
- Report sent
+ {t('errorReport.sentTitle')}
- Thanks! this goes straight to the people who can fix it.
+ {t('errorReport.sentBody')}
@@ -350,7 +360,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
>
- Reference
+ {t('errorReport.reference')}
{
setTimeout(() => setCopied(false), 2000)
}}
sx={{ minWidth: 0 }}
- aria-label='Copy reference'
+ aria-label={t('errorReport.copyReference')}
>
{copied ? : }
)}
@@ -390,15 +400,13 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
- Finish on GitHub
+ {t('errorReport.githubTitle')}
- Nothing has been sent. We've filled in an issue with your
- notes and the diagnostics — review it and post when you're
- happy with it.
+ {t('errorReport.githubBody')}
@@ -411,7 +419,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
onClose()
}}
>
- Open pre-filled issue
+ {t('errorReport.openIssue')}
{
underline='hover'
onClick={onClose}
>
- Close
+ {t('errorReport.close')}
diff --git a/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx b/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx
index 6611c8a..20f31e6 100644
--- a/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx
+++ b/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx
@@ -1,27 +1,38 @@
import { Box, Typography } from '@mui/joy'
+import { Trans, useTranslation } from 'react-i18next'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
+// The store steps embed bolded UI names mid-sentence, so they go through
+// rather than t() — translators keep the emphasis where their own
+// word order needs it.
+const Step = ({ i18nKey, ...props }) => (
+
+ }} />
+
+)
+
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
+ const { t } = useTranslation('settings')
return (
onClose('desktop'),
}}
@@ -30,87 +41,46 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
>
- To cancel your subscription, please follow the instructions for your
- platform (you should cancel through the same platform you used to
- subscribe).
+ {t('nativeCancel.intro')}
- For iOS (iPhone/iPad):
-
-
- 1. Open the Settings app on your device
-
-
- 2. Tap your name at the top of the screen
-
-
- 3. Tap Subscriptions
-
-
- 4. Find and tap Donetick
-
-
- 5. Tap Cancel Subscription
-
-
- Note: If you subscribed through iOS and are using
- the web/desktop version, you must cancel through iOS Settings as
- described above.
+ {t('nativeCancel.iosHeading')}
+
+
+
+
+
+
- For Android:
-
-
- 1. Open the Google Play Store app
-
-
- 2. Tap the profile icon in the top right
-
-
- 3. Tap Payments & subscriptions
-
-
- 4. Tap Subscriptions
-
-
- 5. Find and tap Donetick
-
-
- 6. Tap Cancel subscription
-
-
- Note: If you subscribed through Google Play and are
- using the web/desktop version, you must cancel through Google Play
- as described above.
+ {t('nativeCancel.androidHeading')}
+
+
+
+
+
+
+
- For Web/Desktop Subscriptions:
+ {t('nativeCancel.webHeading')}
- If you originally subscribed through our website or desktop app, you
- can cancel your subscription by going to the Account Settings
- section on our website. using a web browser
-
-
- Important: You must cancel your subscription
- through the same platform where you originally subscribed. If you
- subscribed through the iOS App Store or Google Play Store (even if
- you're now using the web/desktop version), you must cancel
- through that original platform using the instructions above.
+ {t('nativeCancel.webBody')}
+
- Your subscription will remain active until the end of your current
- billing period.
+ {t('nativeCancel.footer')}
diff --git a/src/views/Onboarding/CircleSetupView.jsx b/src/views/Onboarding/CircleSetupView.jsx
index b16d1b0..01f5274 100644
--- a/src/views/Onboarding/CircleSetupView.jsx
+++ b/src/views/Onboarding/CircleSetupView.jsx
@@ -5,6 +5,7 @@ import {
} from '@mui/icons-material'
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
@@ -80,6 +81,7 @@ const IconHalo = ({ icon }) => (
* a gate.
*/
const CircleSetupView = () => {
+ const { t } = useTranslation()
const navigate = useNavigate()
const { showNotification } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
@@ -101,14 +103,20 @@ const CircleSetupView = () => {
const copyCode = () => {
navigator.clipboard.writeText(inviteCode)
- showNotification({ type: 'success', message: 'Code copied to clipboard' })
+ showNotification({
+ type: 'success',
+ message: t('circleSetup.codeCopied'),
+ })
}
const copyLink = () => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/circle/join?code=${inviteCode}`,
)
- showNotification({ type: 'success', message: 'Link copied to clipboard' })
+ showNotification({
+ type: 'success',
+ message: t('circleSetup.linkCopied'),
+ })
}
const joinCircle = async () => {
@@ -120,8 +128,8 @@ const CircleSetupView = () => {
if (resp.ok) {
clearPendingInvite()
showAcknowledgment(
- "Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.",
- 'Request Sent',
+ t('circleSetup.requestSentBody'),
+ t('circleSetup.requestSentTitle'),
finish,
)
} else {
@@ -129,8 +137,8 @@ const CircleSetupView = () => {
type: 'error',
message:
resp.status === 409
- ? 'You are already a member of this circle'
- : 'Failed to join circle',
+ ? t('circleSetup.alreadyMember')
+ : t('circleSetup.joinFailed'),
})
}
} finally {
@@ -190,7 +198,9 @@ const CircleSetupView = () => {
textWrap: 'balance',
}}
>
- {mode === 'invite' ? 'Bring your squad in' : 'Join a circle'}
+ {mode === 'invite'
+ ? t('circleSetup.inviteTitle')
+ : t('circleSetup.joinTitle')}
{
}}
>
{mode === 'invite'
- ? 'Everyone who joins your Circle sees the same chores, takes their own turn, and stays in sync automatically.'
- : "Enter the code you were given and we'll send a request to join — the circle owner just needs to approve it."}
+ ? t('circleSetup.inviteSubtitle')
+ : t('circleSetup.joinSubtitle')}
@@ -233,14 +243,14 @@ const CircleSetupView = () => {
color: inviteCode ? 'text.primary' : 'text.tertiary',
}}
>
- {inviteCode ?? 'Loading…'}
+ {inviteCode ?? t('circleSetup.loadingCode')}
@@ -257,13 +267,13 @@ const CircleSetupView = () => {
disabled={!inviteCode}
onClick={copyLink}
>
- Copy invite link instead
+ {t('circleSetup.copyLinkInstead')}
@@ -277,7 +287,7 @@ const CircleSetupView = () => {
startDecorator={}
onClick={() => setMode('join')}
>
- Join an existing circle instead
+ {t('circleSetup.joinInstead')}
@@ -285,7 +295,7 @@ const CircleSetupView = () => {
setJoinCode(e.target.value)}
size='lg'
@@ -302,7 +312,7 @@ const CircleSetupView = () => {
onClick={joinCircle}
sx={authButtonSx}
>
- Join Circle
+ {t('circleSetup.joinButton')}
@@ -318,7 +328,7 @@ const CircleSetupView = () => {
setMode('invite')
}}
>
- Back
+ {t('circleSetup.back')}
diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx
index 68d13aa..99b7e68 100644
--- a/src/views/Things/ThingsView.jsx
+++ b/src/views/Things/ThingsView.jsx
@@ -33,6 +33,7 @@ import {
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { track } from '../../analytics'
@@ -52,6 +53,7 @@ import CreateThingModal from '../Modals/Inputs/CreateThingModal'
import EditThingStateModal from '../Modals/Inputs/EditThingState'
const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
+ const { t } = useTranslation('things')
const getThingIcon = type => {
if (type === 'text') {
return
@@ -190,7 +192,7 @@ const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
px: 0.75,
}}
>
- {thing?.type}
+ {t(`types.${thing?.type}`, thing?.type)}
@@ -214,6 +216,7 @@ const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
}
const ThingsView = () => {
+ const { t } = useTranslation('things')
const navigate = useNavigate()
const [things, setThings] = useState([])
const [isShowCreateThingModal, setIsShowCreateThingModal] = useState(false)
@@ -325,21 +328,21 @@ const ThingsView = () => {
}
showNotification({
type: 'success',
- title: 'Saved',
- message: 'Thing saved successfully',
+ title: t('notify.savedTitle'),
+ message: t('notify.savedMessage'),
})
})
})
.catch(error => {
if (error?.queued) {
showError({
- title: 'Unable to save thing',
- message: 'You are offline and the request has been queued',
+ title: t('notify.saveFailTitle'),
+ message: t('notify.queuedMessage'),
})
} else {
showError({
- title: 'Unable to save thing',
- message: 'An error occurred while saving the thing',
+ title: t('notify.saveFailTitle'),
+ message: t('notify.saveFailMessage'),
})
}
})
@@ -351,10 +354,10 @@ const ThingsView = () => {
const handleDeleteClick = thing => {
setConfirmModelConfig({
isOpen: true,
- title: 'Delete Things',
- confirmText: 'Delete',
- cancelText: 'Cancel',
- message: 'Are you sure you want to delete this Thing?',
+ title: t('notify.deleteTitle'),
+ confirmText: t('common:delete'),
+ cancelText: t('common:cancel'),
+ message: t('notify.deleteMessage'),
onClose: isConfirmed => {
if (isConfirmed === true) {
DeleteThing(thing.id)
@@ -368,8 +371,8 @@ const ThingsView = () => {
setThings(currentThings)
} else if (response.status === 405) {
showError({
- title: 'Unable to Delete Thing',
- message: 'Unable to delete thing with associated tasks',
+ title: t('notify.deleteBlockedTitle'),
+ message: t('notify.deleteBlockedMessage'),
})
}
// if method not allwo show snackbar:
@@ -377,13 +380,13 @@ const ThingsView = () => {
.catch(error => {
if (error?.queued) {
showError({
- title: 'Unable to delete thing',
- message: 'You are offline and the request has been queued',
+ title: t('notify.deleteFailTitle'),
+ message: t('notify.queuedMessage'),
})
} else {
showError({
- title: 'Unable to delete thing',
- message: 'An error occurred while deleting the thing',
+ title: t('notify.deleteFailTitle'),
+ message: t('notify.deleteFailMessage'),
})
}
})
@@ -416,21 +419,21 @@ const ThingsView = () => {
setThings(currentThings)
showNotification({
type: 'success',
- title: 'Updated',
- message: 'Thing state updated successfully',
+ title: t('notify.updatedTitle'),
+ message: t('notify.updatedMessage'),
})
})
})
.catch(error => {
if (error?.queued) {
showError({
- title: 'Unable to update thing state',
- message: 'You are offline and the request has been queued',
+ title: t('notify.updateFailTitle'),
+ message: t('notify.queuedMessage'),
})
} else {
showError({
- title: 'Unable to update thing state',
- message: 'An error occurred while updating the thing state',
+ title: t('notify.updateFailTitle'),
+ message: t('notify.updateFailMessage'),
})
}
})
@@ -448,15 +451,15 @@ const ThingsView = () => {
setThings(currentThings)
showNotification({
type: 'success',
- title: 'Updated',
- message: 'Thing state updated successfully',
+ title: t('notify.updatedTitle'),
+ message: t('notify.updatedMessage'),
})
})
})
.catch(error => {
showError({
- title: 'Unable to update thing state',
- message: 'An error occurred while updating the thing state',
+ title: t('notify.updateFailTitle'),
+ message: t('notify.updateFailMessage'),
})
})
}
@@ -470,13 +473,10 @@ const ThingsView = () => {
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
- Things
+ {t('view.title')}
- Things are custom fields that can be attached to tasks to capture
- additional information. They can be of type text, number, or
- boolean. You can associate things with tasks and have the task due
- once condition is met
+ {t('view.description')}
@@ -486,7 +486,7 @@ const ThingsView = () => {
>
{
/>
{
@@ -550,10 +550,10 @@ const ThingsView = () => {
}
- title='No things yet'
- description='A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.'
+ title={t('view.emptyTitle')}
+ description={t('view.emptyDescription')}
primaryAction={{
- label: 'Create a thing',
+ label: t('view.emptyAction'),
startDecorator: ,
onClick: () => {
setCreateModalThing(null)
@@ -567,14 +567,14 @@ const ThingsView = () => {
variant='no-results'
fullHeight
icon={}
- title='No things match'
+ title={t('view.noResultsTitle')}
description={
searchTerm
- ? `No thing matches "${searchTerm}".`
- : 'No thing matches the current filter.'
+ ? t('view.noResultsSearch', { term: searchTerm })
+ : t('view.noResultsFilter')
}
primaryAction={{
- label: searchTerm ? 'Clear search' : 'Show all things',
+ label: searchTerm ? t('view.clearSearch') : t('view.showAll'),
onClick: () => {
handleSearchClose()
setTypeFilter('all')
@@ -628,7 +628,9 @@ const ThingsView = () => {
)}
- {thing?.type === 'text' ? 'Edit' : 'Toggle'}
+ {thing?.type === 'text'
+ ? t('common:edit')
+ : t('view.toggle')}
@@ -647,7 +649,7 @@ const ThingsView = () => {
>
- Edit
+ {t('common:edit')}
@@ -666,7 +668,7 @@ const ThingsView = () => {
>
- Delete
+ {t('common:delete')}
diff --git a/src/views/components/AdvancedOptionsSection.jsx b/src/views/components/AdvancedOptionsSection.jsx
index 77c02c5..5239e8c 100644
--- a/src/views/components/AdvancedOptionsSection.jsx
+++ b/src/views/components/AdvancedOptionsSection.jsx
@@ -18,12 +18,13 @@ import {
Switch,
Typography,
} from '@mui/joy'
+import { useTranslation } from 'react-i18next'
-const STRATEGY_OPTIONS = [
- { value: 'keep_last_assigned', label: 'Keep same assignee' },
- { value: 'random', label: 'Random' },
- { value: 'least_completed', label: 'Least completed' },
- { value: 'round_robin', label: 'Round robin' },
+const STRATEGY_VALUES = [
+ 'keep_last_assigned',
+ 'random',
+ 'least_completed',
+ 'round_robin',
]
const FieldRow = ({ children, description, label, onLabelClick }) => (
@@ -67,6 +68,7 @@ export const AdvancedOptionsTrigger = ({
onToggle,
open,
}) => {
+ const { t } = useTranslation('chores')
const showLabel = emptyDisplay === 'icon-text' || open || activeCount > 0
return (
@@ -104,7 +106,7 @@ export const AdvancedOptionsTrigger = ({
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
- More
+ {t('advancedOptions.more')}
@@ -153,6 +155,7 @@ const AdvancedOptionsSection = ({
points,
requireApproval,
}) => {
+ const { t } = useTranslation('chores')
const displayPoints = points <= 0 ? 0 : points
const handleDecrement = () => {
@@ -199,8 +202,8 @@ const AdvancedOptionsSection = ({
>
{/* Points */}
onRequireApprovalChange(!requireApproval)}
>
onIsPrivateChange(!isPrivate) : undefined
@@ -272,8 +275,8 @@ const AdvancedOptionsSection = ({
{/* Assignment strategy — only shown when there are multiple assignees */}
{hasMultipleAssignees && (
@@ -294,8 +297,8 @@ const AdvancedOptionsSection = ({
{hasDueDate ? (
<>
- hrs
+ {t('advancedOptions.hrs')}
}
sx={{ width: 96 }}
@@ -322,7 +325,7 @@ const AdvancedOptionsSection = ({
textColor='text.tertiary'
sx={{ my: 0.5, fontStyle: 'italic' }}
>
- Set a due date to configure completion window
+ {t('advancedOptions.setDueDateHint')}
)}
diff --git a/src/views/components/RepeatPickerField.jsx b/src/views/components/RepeatPickerField.jsx
index af61eb0..c05880d 100644
--- a/src/views/components/RepeatPickerField.jsx
+++ b/src/views/components/RepeatPickerField.jsx
@@ -14,6 +14,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
@@ -54,13 +55,10 @@ const MONTHS = [
'december',
]
-const OCCURRENCE_OPTIONS = [
- { value: 1, label: '1st' },
- { value: 2, label: '2nd' },
- { value: 3, label: '3rd' },
- { value: 4, label: '4th' },
- { value: -1, label: 'Last' },
-]
+const OCCURRENCE_VALUES = [1, 2, 3, 4, -1]
+
+// -1 is the "last occurrence" sentinel; everything else keys off its number.
+const occurrenceKey = value => (value === -1 ? 'last' : String(value))
const defaultMetadata = () => ({
unit: 'days',
@@ -119,26 +117,29 @@ const SectionLabel = ({ children }) => (
)
// Shared time-of-day picker
-const TimeRow = ({ metadata, onUpdate }) => (
-
- Time of day
-
- onUpdate({
- ...metadata,
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- time: moment(
- moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
- ).format(),
- })
- }
- sx={{ width: 120 }}
- />
-
-)
+const TimeRow = ({ metadata, onUpdate }) => {
+ const { t } = useTranslation('chores')
+ return (
+
+ {t('repeat.picker.timeOfDay')}
+
+ onUpdate({
+ ...metadata,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ time: moment(
+ moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
+ ).format(),
+ })
+ }
+ sx={{ width: 120 }}
+ />
+
+ )
+}
const pillListSx = {
'--List-gap': '8px',
@@ -151,51 +152,55 @@ const IntervalSection = ({
frequencyMetadata,
onFrequencyMetadataUpdate,
onFrequencyUpdate,
-}) => (
-
- Repeat every
-
-
- onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
- }
- sx={{ width: 72 }}
- slotProps={{ input: { min: 1, max: 999 } }}
+}) => {
+ const { t } = useTranslation('chores')
+ return (
+
+ {t('repeat.picker.repeatEvery')}
+
+
+ onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
+ }
+ sx={{ width: 72 }}
+ slotProps={{ input: { min: 1, max: 999 } }}
+ />
+
+ {['days', 'weeks', 'months', 'years'].map(unit => (
+
+
+ onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
+ }
+ overlay
+ disableIcon
+ variant='soft'
+ label={t(`repeat.unit.${unit}`)}
+ />
+
+ ))}
+
+
+
-
- {['days', 'weeks', 'months', 'years'].map(unit => (
-
-
- onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
- }
- overlay
- disableIcon
- variant='soft'
- label={unit.charAt(0).toUpperCase() + unit.slice(1)}
- />
-
- ))}
-
-
-
-)
+ )
+}
// Days of week section
const DaysOfWeekSection = ({
frequencyMetadata,
onFrequencyMetadataUpdate,
}) => {
+ const { t } = useTranslation('chores')
const selectedDays = frequencyMetadata?.days || []
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
const selectedOccurrences = frequencyMetadata?.occurrences || []
@@ -216,7 +221,7 @@ const DaysOfWeekSection = ({
return (
- Days
+ {t('repeat.picker.days')}
{DAYS.map(day => (
@@ -226,14 +231,14 @@ const DaysOfWeekSection = ({
overlay
disableIcon
variant='soft'
- label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
+ label={t(`repeat.daysShort.${day}`)}
/>
))}
- Pattern
+ {t('repeat.picker.pattern')}
{[
- { value: 'every_week', label: 'Every week' },
- { value: 'week_of_month', label: 'Specific weeks' },
+ { value: 'every_week', labelKey: 'everyWeek' },
+ { value: 'week_of_month', labelKey: 'specificWeeks' },
].map(opt => (
- Occurrences
+ {t('repeat.picker.occurrences')}
- {OCCURRENCE_OPTIONS.map(opt => (
-
+ {OCCURRENCE_VALUES.map(opt => (
+
toggleOccurrence(opt.value)}
+ checked={selectedOccurrences.includes(opt)}
+ onClick={() => toggleOccurrence(opt)}
overlay
disableIcon
variant='soft'
- label={opt.label}
+ label={t(`repeat.occurrenceShort.${occurrenceKey(opt)}`)}
/>
))}
@@ -317,6 +322,7 @@ const DayOfMonthSection = ({
onFrequencyMetadataUpdate,
onFrequencyUpdate,
}) => {
+ const { t } = useTranslation('chores')
const selectedMonths = frequencyMetadata?.months || []
const toggleMonth = month => {
@@ -328,7 +334,7 @@ const DayOfMonthSection = ({
return (
- Months
+ {t('repeat.picker.months')}
{MONTHS.map(month => (
@@ -338,14 +344,14 @@ const DayOfMonthSection = ({
overlay
disableIcon
variant='soft'
- label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
+ label={t(`repeat.monthsShort.${month}`)}
/>
))}
- Day of month
+ {t('repeat.picker.dayOfMonth')}
{
if (!isOpen) return
@@ -394,7 +401,9 @@ const RepeatPickerField = ({
const hasRepeat = Boolean(value)
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
- const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
+ const displayLabel = hasRepeat
+ ? getRecurrentChipText(value)
+ : t('repeat.picker.trigger')
const displayType = getDisplayType(localFrequencyType)
const handleTypeSelect = type => {
@@ -474,7 +483,7 @@ const RepeatPickerField = ({
{hasRepeat && onClear && (
setIsOpen(false)}
- title='Repeat Schedule'
+ title={t('repeat.picker.modalTitle')}
footer={
{
onClear?.()
@@ -514,15 +523,18 @@ const RepeatPickerField = ({
}
: undefined
}
- secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
- primary={{ label: 'Apply', onClick: handleSave }}
+ secondary={{
+ label: t('common:cancel'),
+ onClick: () => setIsOpen(false),
+ }}
+ primary={{ label: t('repeat.picker.apply'), onClick: handleSave }}
/>
}
>
{/* Frequency type selector */}
- Frequency
+ {t('repeat.picker.frequency')}
{FREQUENCY_TYPES.map(type => (
@@ -532,7 +544,7 @@ const RepeatPickerField = ({
overlay
disableIcon
variant='soft'
- label={type.charAt(0).toUpperCase() + type.slice(1)}
+ label={t(`repeat.freqType.${type}`)}
/>
))}
@@ -543,7 +555,7 @@ const RepeatPickerField = ({
{displayType === 'custom' && (
<>
- Schedule type
+ {t('repeat.picker.scheduleType')}
- i === 0 || i === arr.length - 1
- ? w.charAt(0).toUpperCase() + w.slice(1)
- : w,
- )
- .join(' ')}
+ label={t(`repeat.repeatOnType.${type}`)}
variant='plain'
sx={{ px: 1.5, py: 0.5 }}
slotProps={{