Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b3a0e5a8 | ||
|
|
a92eeaf149 | ||
|
|
86989362fe | ||
|
|
cb81749dbf | ||
|
|
0bac419c3a | ||
|
|
1fa4748168 | ||
|
|
c1318a2cc4 | ||
|
|
cc2e656e14 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
@@ -1,2 +1,13 @@
|
|||||||
LICENSE.md
|
LICENSE.md
|
||||||
dist/
|
dist/
|
||||||
|
build/
|
||||||
|
vendor/
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
resources/
|
||||||
|
.bundle/
|
||||||
|
.agents/
|
||||||
|
.impeccable/
|
||||||
|
package-lock.json
|
||||||
|
Gemfile.lock
|
||||||
|
skills-lock.json
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId "com.donetick.app"
|
applicationId "com.donetick.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 69
|
versionCode 70
|
||||||
versionName "1.2.48"
|
versionName "1.2.49"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -15,7 +15,17 @@ import globals from 'globals'
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ['dist'],
|
ignores: [
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'vendor',
|
||||||
|
'android',
|
||||||
|
'ios',
|
||||||
|
'resources',
|
||||||
|
'public/assets',
|
||||||
|
'.agents',
|
||||||
|
'.impeccable',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
react.configs.flat.recommended,
|
react.configs.flat.recommended,
|
||||||
@@ -51,7 +61,11 @@ export default [
|
|||||||
eslintConfigPrettier, // Disable any rules that conflict with prettier
|
eslintConfigPrettier, // Disable any rules that conflict with prettier
|
||||||
eslintPluginPrettierRecommended,
|
eslintPluginPrettierRecommended,
|
||||||
{
|
{
|
||||||
files: ['*.{js,ts}'],
|
files: [
|
||||||
|
'*.{js,mjs,cjs,ts}',
|
||||||
|
'scripts/**/*.{js,mjs,cjs,ts}',
|
||||||
|
'workers/**/*.{js,mjs,cjs,ts}',
|
||||||
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.node,
|
globals: globals.node,
|
||||||
},
|
},
|
||||||
@@ -72,9 +86,9 @@ export default [
|
|||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
// Formatting rules (indent, space-infix-ops) are owned by prettier here —
|
||||||
|
// re-enabling them fights prettier/prettier and makes --fix unable to converge.
|
||||||
eqeqeq: 'error',
|
eqeqeq: 'error',
|
||||||
indent: ['error', 2],
|
|
||||||
'space-infix-ops': ['error', { int32Hint: false }],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ Build a signed, App Store-ready .ipa
|
|||||||
|
|
||||||
Upload the built .ipa to TestFlight
|
Upload the built .ipa to TestFlight
|
||||||
|
|
||||||
----
|
---
|
||||||
|
|
||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ Build a signed release .apk (for sideloading/testing)
|
|||||||
|
|
||||||
Upload the built .aab to the Play Store (internal testing track by default)
|
Upload the built .aab to the Play Store (internal testing track by default)
|
||||||
|
|
||||||
----
|
---
|
||||||
|
|
||||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
|||||||
@@ -462,12 +462,12 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 69;
|
CURRENT_PROJECT_VERSION = 70;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.2.48;
|
MARKETING_VERSION = 1.2.49;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -485,12 +485,12 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
||||||
CURRENT_PROJECT_VERSION = 69;
|
CURRENT_PROJECT_VERSION = 70;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.2.48;
|
MARKETING_VERSION = 1.2.49;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -504,12 +504,12 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 69;
|
CURRENT_PROJECT_VERSION = 70;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.2.48;
|
MARKETING_VERSION = 1.2.49;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -527,12 +527,12 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
||||||
CURRENT_PROJECT_VERSION = 69;
|
CURRENT_PROJECT_VERSION = 70;
|
||||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.2.48;
|
MARKETING_VERSION = 1.2.49;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "donetick",
|
"name": "donetick",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.48",
|
"version": "1.2.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=20.0.0",
|
||||||
@@ -20,8 +20,9 @@
|
|||||||
"build-cf": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
|
"build-cf": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
|
||||||
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
|
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
|
||||||
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
|
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
|
||||||
"lint": "eslint && prettier -c .",
|
"lint": "eslint && prettier -c . && npm run lint:i18n",
|
||||||
"lint:ci": "eslint -f gha && prettier -c .",
|
"lint:i18n": "node scripts/i18n-audit.mjs",
|
||||||
|
"lint:ci": "eslint -f gha && prettier -c . && npm run lint:i18n",
|
||||||
"lint:fix": "eslint --fix && prettier -w .",
|
"lint:fix": "eslint --fix && prettier -w .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
|
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
|
||||||
|
|||||||
@@ -259,7 +259,18 @@
|
|||||||
"clearFilters": "Clear filters",
|
"clearFilters": "Clear filters",
|
||||||
"count_one": "{{count}} archived task",
|
"count_one": "{{count}} archived task",
|
||||||
"count_other": "{{count}} archived tasks",
|
"count_other": "{{count}} archived tasks",
|
||||||
"matching": " matching \"{{term}}\""
|
"matching": " matching \"{{term}}\"",
|
||||||
|
"filtersTitle": "Filters",
|
||||||
|
"sortArchivedDate": "Archived date",
|
||||||
|
"sortName": "Name",
|
||||||
|
"sortPriority": "Priority",
|
||||||
|
"sortDueDate": "Due date",
|
||||||
|
"noMatchTitle": "No archived tasks match",
|
||||||
|
"noMatchSearch": "Nothing in the archive matches \"{{term}}\".",
|
||||||
|
"noMatchFilters": "There are archived tasks, but none fit the filters that are currently on.",
|
||||||
|
"emptyTitle": "Nothing archived",
|
||||||
|
"emptyDescription": "Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.",
|
||||||
|
"backToTasks": "Back to tasks"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"deleteConfirm": "Are you sure you want to delete this chore?"
|
"deleteConfirm": "Are you sure you want to delete this chore?"
|
||||||
@@ -273,7 +284,11 @@
|
|||||||
"filterUpdated": "Filter Updated",
|
"filterUpdated": "Filter Updated",
|
||||||
"filterUpdatedMsg": "\"{{name}}\" has been updated successfully",
|
"filterUpdatedMsg": "\"{{name}}\" has been updated successfully",
|
||||||
"advancedFilterCreated": "Advanced Filter Created",
|
"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": {
|
"multiToolbar": {
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
@@ -282,7 +297,27 @@
|
|||||||
"skipTitle": "Skip selected tasks (/)",
|
"skipTitle": "Skip selected tasks (/)",
|
||||||
"archiveTitle": "Archive selected tasks (X)",
|
"archiveTitle": "Archive selected tasks (X)",
|
||||||
"archive": "Archive",
|
"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": {
|
"remind": {
|
||||||
"title": "Reminders"
|
"title": "Reminders"
|
||||||
@@ -350,7 +385,11 @@
|
|||||||
"search": "Search",
|
"search": "Search",
|
||||||
"newChore": "New Chore",
|
"newChore": "New Chore",
|
||||||
"unassigned": "Unassigned",
|
"unassigned": "Unassigned",
|
||||||
"changeDueDate": "Change due date"
|
"changeDueDate": "Change due date",
|
||||||
|
"colDue": "Due",
|
||||||
|
"colChore": "Chore",
|
||||||
|
"colAssignee": "Assignee",
|
||||||
|
"colAction": "Action"
|
||||||
},
|
},
|
||||||
"dataError": {
|
"dataError": {
|
||||||
"attachmentsFailed": "Failed to fetch attachments"
|
"attachmentsFailed": "Failed to fetch attachments"
|
||||||
@@ -380,7 +419,21 @@
|
|||||||
"today": "Today",
|
"today": "Today",
|
||||||
"tomorrow": "Tomorrow",
|
"tomorrow": "Tomorrow",
|
||||||
"weekend": "Weekend",
|
"weekend": "Weekend",
|
||||||
"nextWeek": "Next week"
|
"nextWeek": "Next week",
|
||||||
|
"title": "Due Date",
|
||||||
|
"apply": "Apply",
|
||||||
|
"remove": "Remove",
|
||||||
|
"quickDate": "Quick date",
|
||||||
|
"quickTime": "Quick time",
|
||||||
|
"nextMonth": "Next month",
|
||||||
|
"morning": "Morning",
|
||||||
|
"noon": "Noon",
|
||||||
|
"afternoon": "Afternoon",
|
||||||
|
"evening": "Evening",
|
||||||
|
"night": "Night",
|
||||||
|
"customTime": "Custom time",
|
||||||
|
"anytime": "Anytime",
|
||||||
|
"specificTime": "Specific time"
|
||||||
},
|
},
|
||||||
"photoTask": {
|
"photoTask": {
|
||||||
"title": "Scan photo to create task",
|
"title": "Scan photo to create task",
|
||||||
@@ -391,11 +444,32 @@
|
|||||||
"processNatively": "Process Natively",
|
"processNatively": "Process Natively",
|
||||||
"processImage": "Process Image",
|
"processImage": "Process Image",
|
||||||
"createTask": "Create Task",
|
"createTask": "Create Task",
|
||||||
"failedScan": "Failed scan"
|
"failedScan": "Failed scan",
|
||||||
|
"retake": "Retake",
|
||||||
|
"upload": "Upload",
|
||||||
|
"keepAsAttachment": "Keep photo as attachment",
|
||||||
|
"altCaptured": "Captured document",
|
||||||
|
"altProcessing": "Processing",
|
||||||
|
"taskIdentified": "Task identified"
|
||||||
},
|
},
|
||||||
"descriptionPlaceholder": "Enter description...",
|
"descriptionPlaceholder": "Enter description...",
|
||||||
"notifTemplate": {
|
"notificationTemplate": {
|
||||||
"onDueDate": "On due date",
|
"onDueDate": "On due date",
|
||||||
|
"timing": {
|
||||||
|
"before": "Before",
|
||||||
|
"ondue": "Due",
|
||||||
|
"after": "After"
|
||||||
|
},
|
||||||
|
"unitName": {
|
||||||
|
"m": "minutes",
|
||||||
|
"h": "hours",
|
||||||
|
"d": "days"
|
||||||
|
},
|
||||||
|
"unitShort": {
|
||||||
|
"m": "Mins",
|
||||||
|
"h": "Hours",
|
||||||
|
"d": "Days"
|
||||||
|
},
|
||||||
"beforeDue": "{{count}} {{unit}} before due",
|
"beforeDue": "{{count}} {{unit}} before due",
|
||||||
"afterDue": "{{count}} {{unit}} after due",
|
"afterDue": "{{count}} {{unit}} after due",
|
||||||
"errDuplicate": "This notification setting already exists. Please use a different timing.",
|
"errDuplicate": "This notification setting already exists. Please use a different timing.",
|
||||||
@@ -405,5 +479,364 @@
|
|||||||
"dueAlert": "Due Alert",
|
"dueAlert": "Due Alert",
|
||||||
"followUp": "Follow-up",
|
"followUp": "Follow-up",
|
||||||
"rememberFuture": "Remember for Future Tasks"
|
"rememberFuture": "Remember for Future Tasks"
|
||||||
|
},
|
||||||
|
"choreEdit": {
|
||||||
|
"errNameRequired": "Name is required",
|
||||||
|
"errFrequencyInvalid": "Invalid frequency, the {{unit}} should be > 0",
|
||||||
|
"errSelectDayOfWeek": "Please select at least one day of the week",
|
||||||
|
"errSelectDayOccurrence": "Please select at least one day occurrence for the month",
|
||||||
|
"errSelectMonth": "Please select at least one month",
|
||||||
|
"errStartDateRequired": "Start date is required",
|
||||||
|
"errDueDateRequired": "Due date is required",
|
||||||
|
"errThingTrigger": "Thing trigger is invalid",
|
||||||
|
"errTitle": "Please resolve the following errors:",
|
||||||
|
"savedOfflineTitle": "Saved Offline",
|
||||||
|
"savedOfflineMessage": "Your changes will sync when you are back online.",
|
||||||
|
"savedTitle": "Chore Saved",
|
||||||
|
"savedMessage": "Your task has been saved successfully!",
|
||||||
|
"saveFailedTitle": "Save Failed",
|
||||||
|
"saveFailedMessage": "Failed to save chore, please try again.",
|
||||||
|
"uploadFailedTitle": "Upload Failed",
|
||||||
|
"uploadFailedMessage": "Failed to upload attachment.",
|
||||||
|
"scanFailedTitle": "Scan Failed",
|
||||||
|
"scanFailedMessage": "Could not scan the document.",
|
||||||
|
"scanReadFailedMessage": "Could not read the scanned image.",
|
||||||
|
"deleteFailedTitle": "Delete Failed",
|
||||||
|
"deleteAttachmentFailed": "Failed to delete attachment.",
|
||||||
|
"deleteChoreFailed": "Failed to delete chore: {{error}}",
|
||||||
|
"deleteChoreTitle": "Delete Chore",
|
||||||
|
"name": "Name",
|
||||||
|
"nameDesc": "What is the name of this task?",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionDesc": "What is this task about?",
|
||||||
|
"priority": "Priority",
|
||||||
|
"priorityDesc": "How important is this task?",
|
||||||
|
"noPriority": "No Priority",
|
||||||
|
"project": "Project",
|
||||||
|
"projectDesc": "Which project does this task belong to?",
|
||||||
|
"defaultProject": "Default Project",
|
||||||
|
"labels": "Labels",
|
||||||
|
"labelsDesc": "Things to remember about this task or to tag it",
|
||||||
|
"addNewLabel": "Add New Label",
|
||||||
|
"subTasks": "Sub Tasks",
|
||||||
|
"attachments": "Attachments",
|
||||||
|
"attachmentsDesc": "Files attached to this task",
|
||||||
|
"uploadFile": "Upload File",
|
||||||
|
"scan": "Scan",
|
||||||
|
"assignees": "Assignees",
|
||||||
|
"assigneesDesc": "Who can do this task?",
|
||||||
|
"anyone": "Anyone",
|
||||||
|
"rememberFuture": "Remember for Future Tasks",
|
||||||
|
"currentlyAssigned": "Currently Assigned To",
|
||||||
|
"currentlyAssignedDesc": "Who is assigned the next due?",
|
||||||
|
"noAssigneesPlaceholder": "No Assignees yet can perform this task",
|
||||||
|
"selectAssigneePlaceholder": "Select an assignee for this task",
|
||||||
|
"assignStrategy": "Assignment Strategy",
|
||||||
|
"assignStrategyDesc": "How to pick the next assignee for the following task?",
|
||||||
|
"strategy": {
|
||||||
|
"random": "Random",
|
||||||
|
"least_assigned": "Least Assigned",
|
||||||
|
"least_completed": "Least Completed",
|
||||||
|
"keep_last_assigned": "Keep Last Assigned",
|
||||||
|
"random_except_last_assigned": "Random Except Last Assigned",
|
||||||
|
"round_robin": "Round Robin",
|
||||||
|
"no_assignee": "No Assignee"
|
||||||
|
},
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"triggerDueHint": "Due Date will be set when the trigger of the thing is met",
|
||||||
|
"giveDueDate": "Give this task a due date",
|
||||||
|
"giveDueDateHelp": "Task needs to be completed by a specific time",
|
||||||
|
"startWhen": "When does this task start?",
|
||||||
|
"dueWhen": "When is the next first time this task is due?",
|
||||||
|
"setSpecificTime": "Set a specific time",
|
||||||
|
"dueAtSpecifiedTime": "Task will be due at the specified time",
|
||||||
|
"dueEndOfDay": "Task will be due at the end of the day (11:59 PM)",
|
||||||
|
"timeLabel": "Time:",
|
||||||
|
"taskWindow": "Task Window",
|
||||||
|
"taskWindowDesc": "Define when this task can be completed and when it expires",
|
||||||
|
"earliestCompletion": "Set earliest completion time",
|
||||||
|
"earliestCompletionHelp": "Task becomes available to complete X hours before the due date",
|
||||||
|
"hoursLabel": "Hours:",
|
||||||
|
"hoursPlaceholder": "Hours",
|
||||||
|
"afterDueDate": "after due date",
|
||||||
|
"schedulingPrefs": "Scheduling Preferences",
|
||||||
|
"schedulingPrefsDesc": "How to reschedule the next due date?",
|
||||||
|
"rescheduleFromDue": "Reschedule from due date",
|
||||||
|
"rescheduleFromDueHelp": "the next task will be scheduled from the original due date, even if the previous task was completed late",
|
||||||
|
"rescheduleFromCompletion": "Reschedule from completion date",
|
||||||
|
"rescheduleFromCompletionHelp": "the next task will be scheduled from the actual completion date of the previous task",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"notificationsPlanWarning": "Task notifications are not available in the Basic plan. Upgrade to Plus to receive reminders when tasks are due or completed.",
|
||||||
|
"notifyForTask": "Notify for this task",
|
||||||
|
"notifyForTaskHelp": "When should receive notifications for this task",
|
||||||
|
"notificationSchedule": "Notification Schedule",
|
||||||
|
"whoToNotify": "Who to Notify",
|
||||||
|
"allAssignees": "All Assignees",
|
||||||
|
"allAssigneesHelp": "Notify all assignees",
|
||||||
|
"specificGroup": "Specific Group",
|
||||||
|
"specificGroupHelp": "Notify a specific group",
|
||||||
|
"telegramGroupIdLabel": "Telegram Group ID:",
|
||||||
|
"telegramGroupIdPlaceholder": "Telegram Group ID",
|
||||||
|
"taskSettings": "Task Settings:",
|
||||||
|
"pointsSystem": "Points System",
|
||||||
|
"assignPoints": "Assign points for completion",
|
||||||
|
"assignPointsHelp": "Assign points to this task and user will earn points when they completed it",
|
||||||
|
"pointsLabel": "Points:",
|
||||||
|
"pointsPlaceholder": "Points",
|
||||||
|
"approvalRequirement": "Approval Requirement",
|
||||||
|
"requireApproval": "Require admin approval",
|
||||||
|
"requireApprovalHelp": "This task will need approval from an admin before being marked as complete",
|
||||||
|
"privacySettings": "Privacy Settings",
|
||||||
|
"privacyDesc": "Who can see this task?",
|
||||||
|
"public": "Public",
|
||||||
|
"publicHelp": "Everyone in your circle",
|
||||||
|
"limited": "Limited",
|
||||||
|
"limitedHelp": "You and others that are assigned to the task",
|
||||||
|
"limitedDisabledHint": " (No assignees selected, Limited option is disabled)",
|
||||||
|
"createdBy": "Created by",
|
||||||
|
"updatedBy": "Updated by",
|
||||||
|
"archive": "Archive",
|
||||||
|
"unarchive": "Unarchive",
|
||||||
|
"create": "Create"
|
||||||
|
},
|
||||||
|
"addTask": {
|
||||||
|
"title": "Create new task",
|
||||||
|
"defaultProject": "Default Project",
|
||||||
|
"anyone": "Anyone",
|
||||||
|
"taskPlaceholder": "Type your task...",
|
||||||
|
"descriptionChip": "Description",
|
||||||
|
"subtasksChip": "Subtasks",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"createCount": "Create {{count}} Tasks",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,32 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
|
"colors": {
|
||||||
|
"Salmon": "Salmon",
|
||||||
|
"Teal": "Teal",
|
||||||
|
"Sky Blue": "Sky Blue",
|
||||||
|
"Grape": "Grape",
|
||||||
|
"Sunshine": "Sunshine",
|
||||||
|
"Coral": "Coral",
|
||||||
|
"Lavender": "Lavender",
|
||||||
|
"Rose": "Rose",
|
||||||
|
"Charcoal": "Charcoal",
|
||||||
|
"Sienna": "Sienna",
|
||||||
|
"Mint": "Mint",
|
||||||
|
"Amber": "Amber",
|
||||||
|
"Cobalt": "Cobalt",
|
||||||
|
"Emerald": "Emerald",
|
||||||
|
"Peach": "Peach",
|
||||||
|
"Ocean": "Ocean",
|
||||||
|
"Mustard": "Mustard",
|
||||||
|
"Ruby": "Ruby",
|
||||||
|
"Periwinkle": "Periwinkle",
|
||||||
|
"Turquoise": "Turquoise",
|
||||||
|
"Lime": "Lime",
|
||||||
|
"Blush": "Blush",
|
||||||
|
"Ash": "Ash",
|
||||||
|
"Sand": "Sand"
|
||||||
|
},
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
@@ -186,7 +212,18 @@
|
|||||||
"accountPrefs": "Account & preferences",
|
"accountPrefs": "Account & preferences",
|
||||||
"invitePeople": "Invite People",
|
"invitePeople": "Invite People",
|
||||||
"addMembers": "Add members to your circle",
|
"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": {
|
"realtime": {
|
||||||
"newTaskTitle": "New Task Created",
|
"newTaskTitle": "New Task Created",
|
||||||
@@ -217,5 +254,206 @@
|
|||||||
"cancelAll": "Cancel All"
|
"cancelAll": "Cancel All"
|
||||||
},
|
},
|
||||||
"attachments": "Attachments",
|
"attachments": "Attachments",
|
||||||
"fileNumbered": "File {{index}}"
|
"fileNumbered": "File {{index}}",
|
||||||
|
"onboarding": {
|
||||||
|
"sources": {
|
||||||
|
"speak": "Speak",
|
||||||
|
"snap": "Snap",
|
||||||
|
"type": "Type"
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"voiceCaption": "Dates, labels and points from your words",
|
||||||
|
"deviceCaption": "Read on your device",
|
||||||
|
"typeCaption": "#labels @people *points as you type",
|
||||||
|
"trashTitle": "♻️ Take out the trash",
|
||||||
|
"trashDue": "Due tomorrow",
|
||||||
|
"trashRepeat": "Every Monday",
|
||||||
|
"homeLabel": "Home",
|
||||||
|
"vehicleTitle": "🚗 Vehicle Registration Renewal",
|
||||||
|
"vehicleDue": "Due Aug 3",
|
||||||
|
"acTitle": "💨 Change the AC filter",
|
||||||
|
"acDue": "Due Fri",
|
||||||
|
"acRepeat": "Every 3 months"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"fromDueDate": "Reschedule from due date",
|
||||||
|
"fromCompletionDate": "Reschedule from completion date",
|
||||||
|
"due": "Due",
|
||||||
|
"done": "Done",
|
||||||
|
"next": "Next"
|
||||||
|
},
|
||||||
|
"nfc": {
|
||||||
|
"tagOnWasher": "Tag on the washer",
|
||||||
|
"tapToOpen": "Tap to open",
|
||||||
|
"swapFilter": "Swap the washer filter",
|
||||||
|
"done": "Done",
|
||||||
|
"skip": "Skip",
|
||||||
|
"startTimer": "Start timer",
|
||||||
|
"waitingApproval": "{{name}} marked it done · waiting on you",
|
||||||
|
"approve": "Approve",
|
||||||
|
"reject": "Reject",
|
||||||
|
"completed": "Completed",
|
||||||
|
"note": "Used the delicate cycle — filter needs a clean next time.",
|
||||||
|
"metaJustNow": "Just now · ★ 5 pts",
|
||||||
|
"metaLastWeek": "Last week · ★ 5 pts"
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"kitchenTitle": "Kitchen deep clean",
|
||||||
|
"kitchenDue": "Due Sat",
|
||||||
|
"kitchenRepeat": "Every week",
|
||||||
|
"takesTurns": "Takes turns"
|
||||||
|
},
|
||||||
|
"turns": {
|
||||||
|
"binsTitle": "🗑️ Take bins to the curb",
|
||||||
|
"binsDue": "Due Tomorrow",
|
||||||
|
"binsRepeat": "Every week",
|
||||||
|
"completed": "Completed",
|
||||||
|
"metaJustNow": "Just now · ★ 5 pts"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"schoolForms": "School forms",
|
||||||
|
"takeOutTrash": "Take out the trash",
|
||||||
|
"waterPlants": "Water the plants",
|
||||||
|
"today": "Today",
|
||||||
|
"left": "{{count}} left"
|
||||||
|
},
|
||||||
|
"reminders": {
|
||||||
|
"trashTitle": "Take out the trash",
|
||||||
|
"trashBody": "Due in 30 minutes · Bin night",
|
||||||
|
"finishedTitle": "Amalie finished Kitchen deep clean",
|
||||||
|
"finishedBody": "Your turn is next Saturday"
|
||||||
|
},
|
||||||
|
"things": {
|
||||||
|
"waterBill": "Water bill",
|
||||||
|
"acFilter": "AC filter",
|
||||||
|
"trashDay": "Trash day",
|
||||||
|
"dogMedicine": "Dog's medicine",
|
||||||
|
"whoseTurn": "Whose turn to cook",
|
||||||
|
"monthly": "Monthly",
|
||||||
|
"everyThreeMonths": "Every 3 months",
|
||||||
|
"everyMonday": "Every Monday",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"tagBills": "Bills",
|
||||||
|
"tagHome": "Home",
|
||||||
|
"tagPets": "Pets",
|
||||||
|
"tagCooking": "Cooking"
|
||||||
|
},
|
||||||
|
"slides": {
|
||||||
|
"problemTitle": "Stop forgetting the little things",
|
||||||
|
"problemBody": "Donetick remembers the last time it happened, and when it's due next. So nobody else has to.",
|
||||||
|
"captureTitle": "Add it before you forget it",
|
||||||
|
"captureBody": "Type it, say it out loud, or snap a photo. Donetick fills in the due date, priority, labels, and who's on it.",
|
||||||
|
"scheduleTitle": "It comes back exactly when it should",
|
||||||
|
"scheduleBody": "Every week, every 3 months, or a month after you actually did it. Finishing late doesn't throw the rest of the year off.",
|
||||||
|
"circleTitle": "Everyone gets a turn, No more arguing",
|
||||||
|
"circleBody": "Rotate in the order you pick, or at random. Donetick remembers who went last, so turns stay fair.",
|
||||||
|
"remindersTitle": "A nudge right when it matters & Widgets!",
|
||||||
|
"remindersBody": "Get a reminder before something's due, or send a heads-up when someone forgets theirs. Today's list sits on your home screen too."
|
||||||
|
},
|
||||||
|
"carousel": {
|
||||||
|
"skip": "Skip",
|
||||||
|
"ariaLabel": "What you can do with Donetick",
|
||||||
|
"goToStep": "Go to step {{number}}",
|
||||||
|
"slidePosition": "{{index}} of {{total}}",
|
||||||
|
"turnOnReminders": "Turn on reminders",
|
||||||
|
"notNow": "Not now",
|
||||||
|
"next": "Next"
|
||||||
|
},
|
||||||
|
"heardAbout": {
|
||||||
|
"title": "Where'd you hear about Donetick?",
|
||||||
|
"subtitle": "Helps us know what's working. Totally optional.",
|
||||||
|
"detailPlaceholder": "Tell us where",
|
||||||
|
"continue": "Continue",
|
||||||
|
"skip": "Skip",
|
||||||
|
"sources": {
|
||||||
|
"appStore": "App Store / Google Play",
|
||||||
|
"reddit": "Reddit or a forum",
|
||||||
|
"friend": "Friend or family",
|
||||||
|
"video": "YouTube or TikTok",
|
||||||
|
"search": "Search engine",
|
||||||
|
"other": "Something else"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Help us improve Donetick",
|
||||||
|
"subtitle": "Your server, your data. Both of these are off from this point unless you turn them on.",
|
||||||
|
"crashReportsLabel": "Share crash reports",
|
||||||
|
"crashReportsDescription": "When Donetick crashes, send us the error and what led to it (device, app version, the screen you were on) no task content. It's how we catch bugs that never show up in our own testing and ship fixes faster.",
|
||||||
|
"analyticsLabel": "Share anonymous usage analytics",
|
||||||
|
"analyticsDescription": "Send anonymous usage events which features get used, which get ignored so we know what to build next instead of guessing. No task content, no personal data, nothing tied back to you."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,20 @@
|
|||||||
"onTime": "On Time",
|
"onTime": "On Time",
|
||||||
"onTimeLabel": "On time"
|
"onTimeLabel": "On time"
|
||||||
},
|
},
|
||||||
|
"card": {
|
||||||
|
"wasDue": "Was due {{date}}",
|
||||||
|
"daysBeforeDue_one": "{{count}}d before due date",
|
||||||
|
"daysBeforeDue_other": "{{count}}d before due date",
|
||||||
|
"daysAfterDue_one": "{{count}}d after due date",
|
||||||
|
"daysAfterDue_other": "{{count}}d after due date",
|
||||||
|
"hoursBeforeDue_one": "{{count}}h before due date",
|
||||||
|
"hoursBeforeDue_other": "{{count}}h before due date",
|
||||||
|
"hoursAfterDue_one": "{{count}}h after due date",
|
||||||
|
"hoursAfterDue_other": "{{count}}h after due date",
|
||||||
|
"assignedTo": "Assigned to {{name}}",
|
||||||
|
"points_one": "★ {{count}} pt",
|
||||||
|
"points_other": "★ {{count}} pts"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Activity Detail",
|
"title": "Activity Detail",
|
||||||
"openTask": "Open Task",
|
"openTask": "Open Task",
|
||||||
|
|||||||
@@ -3,6 +3,32 @@
|
|||||||
"chooseIcon": "Choose Project Icon",
|
"chooseIcon": "Choose Project Icon",
|
||||||
"availableIcons": "Available Icons"
|
"availableIcons": "Available Icons"
|
||||||
},
|
},
|
||||||
|
"icons": {
|
||||||
|
"FolderOpen": "Folder",
|
||||||
|
"Work": "Work",
|
||||||
|
"Home": "Home",
|
||||||
|
"School": "School",
|
||||||
|
"BusinessCenter": "Business",
|
||||||
|
"Code": "Code",
|
||||||
|
"Build": "Build",
|
||||||
|
"Palette": "Design",
|
||||||
|
"SportsSoccer": "Sports",
|
||||||
|
"FitnessCenter": "Fitness",
|
||||||
|
"ShoppingCart": "Shopping",
|
||||||
|
"Restaurant": "Food",
|
||||||
|
"Flight": "Travel",
|
||||||
|
"Book": "Study",
|
||||||
|
"MusicNote": "Music",
|
||||||
|
"PhotoCamera": "Photo",
|
||||||
|
"Games": "Games",
|
||||||
|
"Science": "Science",
|
||||||
|
"AccountBalance": "Finance",
|
||||||
|
"LocalHospital": "Health",
|
||||||
|
"DirectionsCar": "Auto",
|
||||||
|
"Pets": "Pets",
|
||||||
|
"Garden": "Garden",
|
||||||
|
"Computer": "Tech"
|
||||||
|
},
|
||||||
"defaultChip": "Default",
|
"defaultChip": "Default",
|
||||||
"tasks_one": "{{count}} task",
|
"tasks_one": "{{count}} task",
|
||||||
"tasks_other": "{{count}} tasks",
|
"tasks_other": "{{count}} tasks",
|
||||||
@@ -34,6 +60,11 @@
|
|||||||
"errorUpdate": "Failed to update project",
|
"errorUpdate": "Failed to update project",
|
||||||
"errorCreate": "Failed to create project",
|
"errorCreate": "Failed to create project",
|
||||||
"namePlaceholder": "Enter project name...",
|
"namePlaceholder": "Enter project name...",
|
||||||
"descriptionPlaceholder": "Optional project description..."
|
"descriptionPlaceholder": "Optional project description...",
|
||||||
|
"nameLabel": "Project Name",
|
||||||
|
"descriptionLabel": "Description",
|
||||||
|
"iconLabel": "Project Icon",
|
||||||
|
"selectIcon": "Select Icon",
|
||||||
|
"colorLabel": "Project Color"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,8 @@
|
|||||||
"joinCircle": "Join Circle",
|
"joinCircle": "Join Circle",
|
||||||
"joinedPending": "Joined circle successfully, wait for the circle owner to accept your request.",
|
"joinedPending": "Joined circle successfully, wait for the circle owner to accept your request.",
|
||||||
"alreadyMember": "You are already a member of this circle",
|
"alreadyMember": "You are already a member of this circle",
|
||||||
"joinFailed": "Failed to join circle"
|
"joinFailed": "Failed to join circle",
|
||||||
|
"copyLink": "Copy Link"
|
||||||
},
|
},
|
||||||
"accountSettings": {
|
"accountSettings": {
|
||||||
"title": "Account Settings",
|
"title": "Account Settings",
|
||||||
@@ -423,7 +424,8 @@
|
|||||||
"name": "Weekly Goals",
|
"name": "Weekly Goals",
|
||||||
"description": "Shows weekly progress and family completion stats"
|
"description": "Shows weekly progress and family completion stats"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"detailedDescription": "Customize the layout and visibility of cards in the sidepanel. the section only available on large screen devices such as tablets and desktops.."
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"title": "Theme Preferences",
|
"title": "Theme Preferences",
|
||||||
@@ -457,7 +459,8 @@
|
|||||||
"ymd": "YYYY-MM-DD (ISO)",
|
"ymd": "YYYY-MM-DD (ISO)",
|
||||||
"long": "Long format (e.g., January 1, 2024)",
|
"long": "Long format (e.g., January 1, 2024)",
|
||||||
"short": "Short format (e.g., Jan 1, 2024)"
|
"short": "Short format (e.g., Jan 1, 2024)"
|
||||||
}
|
},
|
||||||
|
"descriptionLong": "Customize language, date format, and regional preferences for your account. These settings will apply throughout the application."
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"title": "Advanced Settings",
|
"title": "Advanced Settings",
|
||||||
@@ -534,7 +537,13 @@
|
|||||||
},
|
},
|
||||||
"passwordChange": {
|
"passwordChange": {
|
||||||
"minLength": "Password must be at least 8 characters",
|
"minLength": "Password must be at least 8 characters",
|
||||||
"maxLength": "Password must be less than 64 characters"
|
"maxLength": "Password must be less than 64 characters",
|
||||||
|
"description": "Choose a password between 8 and 64 characters.",
|
||||||
|
"mismatch": "Passwords do not match",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"newPasswordPlaceholder": "Enter password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"confirmPasswordPlaceholder": "Repeat password"
|
||||||
},
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"cancelledTitle": "Payment has been cancelled",
|
"cancelledTitle": "Payment has been cancelled",
|
||||||
@@ -566,7 +575,12 @@
|
|||||||
"includeAssets": "Include uploaded files and assets",
|
"includeAssets": "Include uploaded files and assets",
|
||||||
"warningLabel": "Warning:",
|
"warningLabel": "Warning:",
|
||||||
"restoreWarning": "Restoring a backup will replace all your current data. This action cannot be undone.",
|
"restoreWarning": "Restoring a backup will replace all your current data. This action cannot be undone.",
|
||||||
"restoreKeyPlaceholder": "Enter the encryption key used for this backup"
|
"restoreKeyPlaceholder": "Enter the encryption key used for this backup",
|
||||||
|
"title": "Backup & Restore",
|
||||||
|
"createTab": "Create Backup",
|
||||||
|
"restoreTab": "Restore Backup",
|
||||||
|
"creating": "Creating backup...",
|
||||||
|
"restoring": "Restoring backup..."
|
||||||
},
|
},
|
||||||
"deletion": {
|
"deletion": {
|
||||||
"passwordRequired": "Please enter your password to continue",
|
"passwordRequired": "Please enter your password to continue",
|
||||||
@@ -576,9 +590,50 @@
|
|||||||
"transferIntro": "You own circles that require ownership transfer before deletion. Please select new owners:",
|
"transferIntro": "You own circles that require ownership transfer before deletion. Please select new owners:",
|
||||||
"selectOwner": "Select new owner",
|
"selectOwner": "Select new owner",
|
||||||
"confirmPrompt": "Please enter your password and type DELETE to confirm",
|
"confirmPrompt": "Please enter your password and type DELETE to confirm",
|
||||||
"typeDelete": "DELETE"
|
"typeDelete": "DELETE",
|
||||||
|
"warningLead": "This action cannot be undone.",
|
||||||
|
"warningIntro": "Deleting your account will permanently remove:",
|
||||||
|
"removesProfile": "Your user profile and authentication data",
|
||||||
|
"removesChores": "All your chores, chore history, and time tracking sessions",
|
||||||
|
"removesTokens": "API tokens, MFA sessions, and password reset tokens",
|
||||||
|
"removesStorage": "Storage files and usage data",
|
||||||
|
"removesPoints": "Points history and notifications",
|
||||||
|
"removesCircles": "Circle memberships and relationships",
|
||||||
|
"passwordToContinue": "Enter your password to continue",
|
||||||
|
"circleLabel": "Circle: {{name}}",
|
||||||
|
"newOwner": "New Owner",
|
||||||
|
"logoutNotice": "On successful deletion, you will be logged out and redirected to the login page.",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"typeToConfirm": "Type \"{{word}}\" to confirm"
|
||||||
},
|
},
|
||||||
"realtime": {
|
"realtime": {
|
||||||
"titleSse": "Real-time Updates (SSE)"
|
"titleSse": "Real-time Updates (SSE)",
|
||||||
|
"subtitleSse": "Get instant notifications via Server-Sent Events"
|
||||||
|
},
|
||||||
|
"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 <b>Settings</b> app on your device",
|
||||||
|
"iosStep2": "2. Tap your name at the top of the screen",
|
||||||
|
"iosStep3": "3. Tap <b>Subscriptions</b>",
|
||||||
|
"iosStep4": "4. Find and tap <b>Donetick</b>",
|
||||||
|
"iosStep5": "5. Tap <b>Cancel Subscription</b>",
|
||||||
|
"iosNote": "<b>Note:</b> 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 <b>Google Play Store</b> app",
|
||||||
|
"androidStep2": "2. Tap the profile icon in the top right",
|
||||||
|
"androidStep3": "3. Tap <b>Payments & subscriptions</b>",
|
||||||
|
"androidStep4": "4. Tap <b>Subscriptions</b>",
|
||||||
|
"androidStep5": "5. Find and tap <b>Donetick</b>",
|
||||||
|
"androidStep6": "6. Tap <b>Cancel subscription</b>",
|
||||||
|
"androidNote": "<b>Note:</b> 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": "<b>Important:</b> 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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,47 @@
|
|||||||
"visualization": "Data Visualization",
|
"visualization": "Data Visualization",
|
||||||
"changeHistory": "Change History",
|
"changeHistory": "Change History",
|
||||||
"updated": "Updated"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,11 @@
|
|||||||
"endTime": "End Time",
|
"endTime": "End Time",
|
||||||
"leaveEmpty": "Leave empty if session is ongoing",
|
"leaveEmpty": "Leave empty if session is ongoing",
|
||||||
"noSessionForChore": "No timer session found for this chore.",
|
"noSessionForChore": "No timer session found for this chore.",
|
||||||
"saveChanges": "Save Changes"
|
"saveChanges": "Save Changes",
|
||||||
|
"emptySessionsTitle": "No work sessions yet",
|
||||||
|
"emptySessionsDescription": "Start the timer on this task and each session lands here.",
|
||||||
|
"percentActive": "{{percent}}% active",
|
||||||
|
"activityTimeline": "Activity Timeline",
|
||||||
|
"liveSession": "Live Session",
|
||||||
|
"live": "Live"
|
||||||
}
|
}
|
||||||
|
|||||||
137
scripts/i18n-audit.mjs
Normal file
137
scripts/i18n-audit.mjs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fails if any t() / <Trans> 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()')
|
||||||
|
|
||||||
|
// <Trans i18nKey='x' ns='y'> 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')
|
||||||
@@ -30,13 +30,13 @@ function getRelativeLabel(notification, t) {
|
|||||||
const { unit, value } = notification
|
const { unit, value } = notification
|
||||||
const numericValue = Number(value)
|
const numericValue = Number(value)
|
||||||
if (numericValue === 0) {
|
if (numericValue === 0) {
|
||||||
return t('notifTemplate.onDueDate')
|
return t('notificationTemplate.onDueDate')
|
||||||
}
|
}
|
||||||
const unitName = t(`notifTemplate.unitName.${unit}`)
|
const unitName = t(`notificationTemplate.unitName.${unit}`)
|
||||||
const absValue = Math.abs(numericValue)
|
const absValue = Math.abs(numericValue)
|
||||||
return numericValue < 0
|
return numericValue < 0
|
||||||
? t('notifTemplate.beforeDue', { count: absValue, unit: unitName })
|
? t('notificationTemplate.beforeDue', { count: absValue, unit: unitName })
|
||||||
: t('notifTemplate.afterDue', { count: absValue, unit: unitName })
|
: t('notificationTemplate.afterDue', { count: absValue, unit: unitName })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions to convert between internal value and UI representation
|
// Helper functions to convert between internal value and UI representation
|
||||||
@@ -223,7 +223,7 @@ const NotificationTemplate = ({
|
|||||||
if (!currentNotification) return
|
if (!currentNotification) return
|
||||||
|
|
||||||
if (isDuplicate(currentNotification, idx, currentList)) {
|
if (isDuplicate(currentNotification, idx, currentList)) {
|
||||||
setError(t('notifTemplate.errDuplicate'))
|
setError(t('notificationTemplate.errDuplicate'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,14 +235,14 @@ const NotificationTemplate = ({
|
|||||||
|
|
||||||
if (type === 'due') {
|
if (type === 'due') {
|
||||||
if (notificationsRef.current.some(n => Number(n.value) === 0)) {
|
if (notificationsRef.current.some(n => Number(n.value) === 0)) {
|
||||||
setError(t('notifTemplate.errOneDue'))
|
setError(t('notificationTemplate.errOneDue'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newNotification = { value: 0, unit: 'm' }
|
newNotification = { value: 0, unit: 'm' }
|
||||||
} else {
|
} else {
|
||||||
newNotification = getSmartSuggestion(type)
|
newNotification = getSmartSuggestion(type)
|
||||||
if (!newNotification) {
|
if (!newNotification) {
|
||||||
setError(t('notifTemplate.errAllConfigured', { type }))
|
setError(t('notificationTemplate.errAllConfigured', { type }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -578,7 +578,7 @@ const NotificationTemplate = ({
|
|||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value === 'ondue' && hasOnDueElsewhere}
|
disabled={opt.value === 'ondue' && hasOnDueElsewhere}
|
||||||
>
|
>
|
||||||
{t(`notifTemplate.timing.${opt.value}`)}
|
{t(`notificationTemplate.timing.${opt.value}`)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -641,7 +641,7 @@ const NotificationTemplate = ({
|
|||||||
>
|
>
|
||||||
{timeUnits.map(opt => (
|
{timeUnits.map(opt => (
|
||||||
<Option key={opt.value} value={opt.value}>
|
<Option key={opt.value} value={opt.value}>
|
||||||
{t(`notifTemplate.unitShort.${opt.value}`)}
|
{t(`notificationTemplate.unitShort.${opt.value}`)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -691,7 +691,7 @@ const NotificationTemplate = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('notifTemplate.reminder')}
|
{t('notificationTemplate.reminder')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addSmartNotification('due')}
|
onClick={() => addSmartNotification('due')}
|
||||||
@@ -713,7 +713,7 @@ const NotificationTemplate = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('notifTemplate.dueAlert')}
|
{t('notificationTemplate.dueAlert')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addSmartNotification('followup')}
|
onClick={() => addSmartNotification('followup')}
|
||||||
@@ -732,7 +732,7 @@ const NotificationTemplate = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('notifTemplate.followUp')}
|
{t('notificationTemplate.followUp')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{showSaveDefault && (
|
{showSaveDefault && (
|
||||||
@@ -765,7 +765,7 @@ const NotificationTemplate = ({
|
|||||||
setShowSaveDefault(false)
|
setShowSaveDefault(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('notifTemplate.rememberFuture')}
|
{t('notificationTemplate.rememberFuture')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -81,12 +81,12 @@ const SSESettings = () => {
|
|||||||
{t('realtime.titleSse')}
|
{t('realtime.titleSse')}
|
||||||
{!isPlusAccount(userProfile) && (
|
{!isPlusAccount(userProfile) && (
|
||||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||||
Plus Feature
|
{t('common.plusFeature')}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm' color='neutral'>
|
<Typography level='body-sm' color='neutral'>
|
||||||
Get instant notifications via Server-Sent Events
|
{t('realtime.subtitleSse')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{isSSEEnabled() && isPlusAccount(userProfile) && (
|
{isSSEEnabled() && isPlusAccount(userProfile) && (
|
||||||
|
|||||||
@@ -63,20 +63,20 @@ const UserProfileAvatar = () => {
|
|||||||
const isPlusUser = isPlusAccount(userProfile)
|
const isPlusUser = isPlusAccount(userProfile)
|
||||||
|
|
||||||
const getSubscriptionStatus = () => {
|
const getSubscriptionStatus = () => {
|
||||||
if (!userProfile) return 'Free'
|
if (!userProfile) return t('userMenu.planFree')
|
||||||
|
|
||||||
if (userProfile.subscription === 'active') {
|
if (userProfile.subscription === 'active') {
|
||||||
return 'Plus'
|
return t('userMenu.planPlus')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
userProfile.subscription === 'cancelled' &&
|
userProfile.subscription === 'cancelled' &&
|
||||||
moment().isBefore(userProfile.expiration)
|
moment().isBefore(userProfile.expiration)
|
||||||
) {
|
) {
|
||||||
return 'Plus (expires soon)'
|
return t('userMenu.planPlusExpiring')
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Free'
|
return t('userMenu.planFree')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -270,7 +270,7 @@ const UserProfileAvatar = () => {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Impersonating
|
{t('userMenu.impersonating')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -296,7 +296,9 @@ const UserProfileAvatar = () => {
|
|||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||||
{isImpersonating ? 'Switch User' : 'Impersonate User'}
|
{isImpersonating
|
||||||
|
? t('userMenu.switchUser')
|
||||||
|
: t('userMenu.impersonateUser')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
@@ -432,13 +434,15 @@ const UserProfileAvatar = () => {
|
|||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||||
{isDarkMode ? 'Switch to Light' : 'Switch to Dark'}
|
{isDarkMode
|
||||||
|
? t('userMenu.switchToLight')
|
||||||
|
: t('userMenu.switchToDark')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||||
>
|
>
|
||||||
Toggle theme appearance
|
{t('userMenu.toggleThemeAppearance')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -465,9 +469,11 @@ const UserProfileAvatar = () => {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Upgrade to Plus
|
{t('userMenu.upgradeToPlus')}
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-xs'>
|
||||||
|
{t('userMenu.unlockPremium')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-xs'>Unlock premium features</Typography>
|
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
@@ -516,7 +522,7 @@ const UserProfileAvatar = () => {
|
|||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ fontWeight: 500, color: 'var(--joy-palette-danger-500)' }}
|
sx={{ fontWeight: 500, color: 'var(--joy-palette-danger-500)' }}
|
||||||
>
|
>
|
||||||
Logout
|
{t('logout')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import DurationInput from '../../components/common/DurationInput'
|
import DurationInput from '../../components/common/DurationInput'
|
||||||
@@ -94,6 +95,7 @@ const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
|||||||
const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
|
const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
|
||||||
const NO_DUE_DATE_ALLOWED_TYPE = ['trigger']
|
const NO_DUE_DATE_ALLOWED_TYPE = ['trigger']
|
||||||
const ChoreEdit = () => {
|
const ChoreEdit = () => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||||
useUserProfile()
|
useUserProfile()
|
||||||
|
|
||||||
@@ -207,16 +209,18 @@ const ChoreEdit = () => {
|
|||||||
const errors = {}
|
const errors = {}
|
||||||
|
|
||||||
if (name.trim() === '') {
|
if (name.trim() === '') {
|
||||||
errors.name = 'Name is required'
|
errors.name = t('choreEdit.errNameRequired')
|
||||||
}
|
}
|
||||||
if (frequencyType === 'interval' && !frequency > 0) {
|
if (frequencyType === 'interval' && !frequency > 0) {
|
||||||
errors.frequency = `Invalid frequency, the ${frequencyMetadata.unit} should be > 0`
|
errors.frequency = t('choreEdit.errFrequencyInvalid', {
|
||||||
|
unit: frequencyMetadata.unit,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
frequencyType === 'days_of_the_week' &&
|
frequencyType === 'days_of_the_week' &&
|
||||||
frequencyMetadata['days']?.length === 0
|
frequencyMetadata['days']?.length === 0
|
||||||
) {
|
) {
|
||||||
errors.frequency = 'Please select at least one day of the week'
|
errors.frequency = t('choreEdit.errSelectDayOfWeek')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate advanced scheduling patterns
|
// Validate advanced scheduling patterns
|
||||||
@@ -226,14 +230,13 @@ const ChoreEdit = () => {
|
|||||||
(!frequencyMetadata?.occurrences ||
|
(!frequencyMetadata?.occurrences ||
|
||||||
frequencyMetadata.occurrences.length === 0)
|
frequencyMetadata.occurrences.length === 0)
|
||||||
) {
|
) {
|
||||||
errors.frequency =
|
errors.frequency = t('choreEdit.errSelectDayOccurrence')
|
||||||
'Please select at least one day occurrence for the month'
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
frequencyType === 'day_of_the_month' &&
|
frequencyType === 'day_of_the_month' &&
|
||||||
frequencyMetadata['months']?.length === 0
|
frequencyMetadata['months']?.length === 0
|
||||||
) {
|
) {
|
||||||
errors.frequency = 'Please select at least one month'
|
errors.frequency = t('choreEdit.errSelectMonth')
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
dueDate === null &&
|
dueDate === null &&
|
||||||
@@ -243,14 +246,14 @@ const ChoreEdit = () => {
|
|||||||
if (REPEAT_ON_TYPE.includes(frequencyType)) {
|
if (REPEAT_ON_TYPE.includes(frequencyType)) {
|
||||||
console.log('VALIDATION:', dueDate, frequencyType)
|
console.log('VALIDATION:', dueDate, frequencyType)
|
||||||
|
|
||||||
errors.dueDate = 'Start date is required'
|
errors.dueDate = t('choreEdit.errStartDateRequired')
|
||||||
} else {
|
} else {
|
||||||
errors.dueDate = 'Due date is required'
|
errors.dueDate = t('choreEdit.errDueDateRequired')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (frequencyType === 'trigger') {
|
if (frequencyType === 'trigger') {
|
||||||
if (!isThingValid) {
|
if (!isThingValid) {
|
||||||
errors.thingTrigger = 'Thing trigger is invalid'
|
errors.thingTrigger = t('choreEdit.errThingTrigger')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +266,7 @@ const ChoreEdit = () => {
|
|||||||
<ListItem key={key}>{errors[key]}</ListItem>
|
<ListItem key={key}>{errors[key]}</ListItem>
|
||||||
))
|
))
|
||||||
showError({
|
showError({
|
||||||
title: 'Please resolve the following errors:',
|
title: t('choreEdit.errTitle'),
|
||||||
message: <List>{errorList}</List>,
|
message: <List>{errorList}</List>,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -417,13 +420,13 @@ const ChoreEdit = () => {
|
|||||||
result?.res?._pendingCreate
|
result?.res?._pendingCreate
|
||||||
) {
|
) {
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: 'Saved Offline',
|
title: t('choreEdit.savedOfflineTitle'),
|
||||||
message: 'Your changes will sync when you are back online.',
|
message: t('choreEdit.savedOfflineMessage'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: 'Chore Saved',
|
title: t('choreEdit.savedTitle'),
|
||||||
message: 'Your task has been saved successfully!',
|
message: t('choreEdit.savedMessage'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Navigate('/chores')
|
Navigate('/chores')
|
||||||
@@ -431,10 +434,10 @@ const ChoreEdit = () => {
|
|||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Failed to save chore:', error)
|
console.error('Failed to save chore:', error)
|
||||||
showError({
|
showError({
|
||||||
title: 'Save Failed',
|
title: t('choreEdit.saveFailedTitle'),
|
||||||
message: error?.isServerMessage
|
message: error?.isServerMessage
|
||||||
? error.message
|
? error.message
|
||||||
: 'Failed to save chore, please try again.',
|
: t('choreEdit.saveFailedMessage'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -693,8 +696,8 @@ const ChoreEdit = () => {
|
|||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Upload Failed',
|
title: t('choreEdit.uploadFailedTitle'),
|
||||||
message: 'Failed to upload attachment.',
|
message: t('choreEdit.uploadFailedMessage'),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -710,8 +713,8 @@ const ChoreEdit = () => {
|
|||||||
])
|
])
|
||||||
} catch {
|
} catch {
|
||||||
showError({
|
showError({
|
||||||
title: 'Upload Failed',
|
title: t('choreEdit.uploadFailedTitle'),
|
||||||
message: 'Failed to upload attachment.',
|
message: t('choreEdit.uploadFailedMessage'),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingAttachment(false)
|
setIsUploadingAttachment(false)
|
||||||
@@ -725,16 +728,16 @@ const ChoreEdit = () => {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (error || !image) {
|
if (error || !image) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Scan Failed',
|
title: t('choreEdit.scanFailedTitle'),
|
||||||
message: error || 'Could not scan the document.',
|
message: error || t('choreEdit.scanFailedMessage'),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
||||||
if (!file) {
|
if (!file) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Scan Failed',
|
title: t('choreEdit.scanFailedTitle'),
|
||||||
message: 'Could not read the scanned image.',
|
message: t('choreEdit.scanReadFailedMessage'),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -744,10 +747,10 @@ const ChoreEdit = () => {
|
|||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setConfirmModelConfig({
|
setConfirmModelConfig({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
title: 'Delete Chore',
|
title: t('choreEdit.deleteChoreTitle'),
|
||||||
confirmText: 'Delete',
|
confirmText: t('common:delete'),
|
||||||
cancelText: 'Cancel',
|
cancelText: t('common:cancel'),
|
||||||
message: 'Are you sure you want to delete this chore?',
|
message: t('edit.deleteConfirm'),
|
||||||
onClose: isConfirmed => {
|
onClose: isConfirmed => {
|
||||||
if (isConfirmed === true) {
|
if (isConfirmed === true) {
|
||||||
deleteChores.mutate([choreId], {
|
deleteChores.mutate([choreId], {
|
||||||
@@ -756,8 +759,10 @@ const ChoreEdit = () => {
|
|||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
showError({
|
showError({
|
||||||
title: 'Delete Failed',
|
title: t('choreEdit.deleteFailedTitle'),
|
||||||
message: `Failed to delete chore: ${error.message}`,
|
message: t('choreEdit.deleteChoreFailed', {
|
||||||
|
error: error.message,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -789,10 +794,8 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<FormControl error={errors.name}>
|
<FormControl error={errors.name}>
|
||||||
<Typography level='h4'>Name</Typography>
|
<Typography level='h4'>{t('choreEdit.name')}</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>{t('choreEdit.nameDesc')}</Typography>
|
||||||
What is the name of this task?
|
|
||||||
</Typography>
|
|
||||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||||
<FormHelperText error>{errors.name}</FormHelperText>
|
<FormHelperText error>{errors.name}</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -800,8 +803,10 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<FormControl error={errors.description}>
|
<FormControl error={errors.description}>
|
||||||
<Typography level='h4'>Description</Typography>
|
<Typography level='h4'>{t('choreEdit.description')}</Typography>
|
||||||
<Typography level='body-md'>What is this task about?</Typography>
|
<Typography level='body-md'>
|
||||||
|
{t('choreEdit.descriptionDesc')}
|
||||||
|
</Typography>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
value={description}
|
value={description}
|
||||||
onChange={setDescription}
|
onChange={setDescription}
|
||||||
@@ -814,8 +819,8 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Priority</Typography>
|
<Typography level='h4'>{t('choreEdit.priority')}</Typography>
|
||||||
<Typography level='body-md'>How important is this task?</Typography>
|
<Typography level='body-md'>{t('choreEdit.priorityDesc')}</Typography>
|
||||||
|
|
||||||
{/* Priority Chip Selection */}
|
{/* Priority Chip Selection */}
|
||||||
<Box
|
<Box
|
||||||
@@ -861,7 +866,7 @@ const ChoreEdit = () => {
|
|||||||
minHeight: 34,
|
minHeight: 34,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No Priority
|
{t('choreEdit.noPriority')}
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -869,9 +874,9 @@ const ChoreEdit = () => {
|
|||||||
{/* Project Selection - Show only if there are multiple projects */}
|
{/* Project Selection - Show only if there are multiple projects */}
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Project</Typography>
|
<Typography level='h4'>{t('choreEdit.project')}</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Which project does this task belong to?
|
{t('choreEdit.projectDesc')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Select
|
<Select
|
||||||
value={projectId}
|
value={projectId}
|
||||||
@@ -906,7 +911,7 @@ const ChoreEdit = () => {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
Default Project
|
{t('choreEdit.defaultProject')}
|
||||||
</Box>
|
</Box>
|
||||||
</Option>
|
</Option>
|
||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
@@ -947,10 +952,8 @@ const ChoreEdit = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Labels</Typography>
|
<Typography level='h4'>{t('choreEdit.labels')}</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>{t('choreEdit.labelsDesc')}</Typography>
|
||||||
Things to remember about this task or to tag it
|
|
||||||
</Typography>
|
|
||||||
<Select
|
<Select
|
||||||
multiple
|
multiple
|
||||||
onChange={(event, newValue) => {
|
onChange={(event, newValue) => {
|
||||||
@@ -1010,13 +1013,13 @@ const ChoreEdit = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Add />
|
<Add />
|
||||||
Add New Label
|
{t('choreEdit.addNewLabel')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='h4'>Sub Tasks</Typography>
|
<Typography level='h4'>{t('choreEdit.subTasks')}</Typography>
|
||||||
{/* <FormControl sx={{ mt: 1 }}>
|
{/* <FormControl sx={{ mt: 1 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@@ -1049,8 +1052,10 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box mt={3}>
|
<Box mt={3}>
|
||||||
<Typography level='h4'>Attachments</Typography>
|
<Typography level='h4'>{t('choreEdit.attachments')}</Typography>
|
||||||
<Typography level='body-md'>Files attached to this task</Typography>
|
<Typography level='body-md'>
|
||||||
|
{t('choreEdit.attachmentsDesc')}
|
||||||
|
</Typography>
|
||||||
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
|
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
@@ -1140,8 +1145,8 @@ const ChoreEdit = () => {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
showError({
|
showError({
|
||||||
title: 'Delete Failed',
|
title: t('choreEdit.deleteFailedTitle'),
|
||||||
message: 'Failed to delete attachment.',
|
message: t('choreEdit.deleteAttachmentFailed'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -1166,8 +1171,8 @@ const ChoreEdit = () => {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
showError({
|
showError({
|
||||||
title: 'Delete Failed',
|
title: t('choreEdit.deleteFailedTitle'),
|
||||||
message: 'Failed to delete attachment.',
|
message: t('choreEdit.deleteAttachmentFailed'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -1188,7 +1193,7 @@ const ChoreEdit = () => {
|
|||||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||||
loading={isUploadingAttachment}
|
loading={isUploadingAttachment}
|
||||||
>
|
>
|
||||||
Upload File
|
{t('choreEdit.uploadFile')}
|
||||||
<input
|
<input
|
||||||
type='file'
|
type='file'
|
||||||
hidden
|
hidden
|
||||||
@@ -1208,7 +1213,7 @@ const ChoreEdit = () => {
|
|||||||
disabled={isUploadingAttachment}
|
disabled={isUploadingAttachment}
|
||||||
onClick={handleScanAttachment}
|
onClick={handleScanAttachment}
|
||||||
>
|
>
|
||||||
Scan
|
{t('choreEdit.scan')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1219,8 +1224,10 @@ const ChoreEdit = () => {
|
|||||||
{/* Section 2: Assignment & Responsibility */}
|
{/* Section 2: Assignment & Responsibility */}
|
||||||
<Box mb={4}>
|
<Box mb={4}>
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Assignees</Typography>
|
<Typography level='h4'>{t('choreEdit.assignees')}</Typography>
|
||||||
<Typography level='body-md'>Who can do this task?</Typography>
|
<Typography level='body-md'>
|
||||||
|
{t('choreEdit.assigneesDesc')}
|
||||||
|
</Typography>
|
||||||
<Card>
|
<Card>
|
||||||
<List
|
<List
|
||||||
orientation='horizontal'
|
orientation='horizontal'
|
||||||
@@ -1242,7 +1249,7 @@ const ChoreEdit = () => {
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label='Anyone'
|
label={t('choreEdit.anyone')}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -1308,7 +1315,7 @@ const ChoreEdit = () => {
|
|||||||
setShowSaveAssigneeDefault(false)
|
setShowSaveAssigneeDefault(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remember for Future Tasks
|
{t('choreEdit.rememberFuture')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -1323,15 +1330,17 @@ const ChoreEdit = () => {
|
|||||||
assignStrategyValue === 'no_assignee' ? 'none' : 'block',
|
assignStrategyValue === 'no_assignee' ? 'none' : 'block',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='h4'>Currently Assigned To</Typography>
|
<Typography level='h4'>
|
||||||
|
{t('choreEdit.currentlyAssigned')}
|
||||||
|
</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Who is assigned the next due?
|
{t('choreEdit.currentlyAssignedDesc')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Select
|
<Select
|
||||||
placeholder={
|
placeholder={
|
||||||
assignees.length === 0
|
assignees.length === 0
|
||||||
? 'No Assignees yet can perform this task'
|
? t('choreEdit.noAssigneesPlaceholder')
|
||||||
: 'Select an assignee for this task'
|
: t('choreEdit.selectAssigneePlaceholder')
|
||||||
}
|
}
|
||||||
disabled={assignees.length === 0}
|
disabled={assignees.length === 0}
|
||||||
value={assignedToValue}
|
value={assignedToValue}
|
||||||
@@ -1348,9 +1357,11 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='h4'>Assignment Strategy</Typography>
|
<Typography level='h4'>
|
||||||
|
{t('choreEdit.assignStrategy')}
|
||||||
|
</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
How to pick the next assignee for the following task?
|
{t('choreEdit.assignStrategyDesc')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Card>
|
<Card>
|
||||||
<List
|
<List
|
||||||
@@ -1369,10 +1380,7 @@ const ChoreEdit = () => {
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={item
|
label={t(`choreEdit.strategy.${item}`)}
|
||||||
.split('_')
|
|
||||||
.map(x => x.charAt(0).toUpperCase() + x.slice(1))
|
|
||||||
.join(' ')}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -1412,11 +1420,13 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
<Box mt={3} mb={3}>
|
<Box mt={3} mb={3}>
|
||||||
<Typography level='h4'>
|
<Typography level='h4'>
|
||||||
{REPEAT_ON_TYPE.includes(frequencyType) ? 'Start Date' : 'Due Date'}
|
{REPEAT_ON_TYPE.includes(frequencyType)
|
||||||
|
? t('choreEdit.startDate')
|
||||||
|
: t('choreEdit.dueDate')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{frequencyType === 'trigger' && !dueDate && (
|
{frequencyType === 'trigger' && !dueDate && (
|
||||||
<Typography level='body-sm'>
|
<Typography level='body-sm'>
|
||||||
Due Date will be set when the trigger of the thing is met
|
{t('choreEdit.triggerDueHint')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1442,11 +1452,9 @@ const ChoreEdit = () => {
|
|||||||
defaultChecked={dueDate !== null}
|
defaultChecked={dueDate !== null}
|
||||||
checked={dueDate !== null}
|
checked={dueDate !== null}
|
||||||
overlay
|
overlay
|
||||||
label='Give this task a due date'
|
label={t('choreEdit.giveDueDate')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>{t('choreEdit.giveDueDateHelp')}</FormHelperText>
|
||||||
Task needs to be completed by a specific time
|
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
@@ -1454,8 +1462,8 @@ const ChoreEdit = () => {
|
|||||||
<FormControl error={Boolean(errors.dueDate)} sx={{ mt: 2 }}>
|
<FormControl error={Boolean(errors.dueDate)} sx={{ mt: 2 }}>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
{REPEAT_ON_TYPE.includes(frequencyType)
|
{REPEAT_ON_TYPE.includes(frequencyType)
|
||||||
? 'When does this task start?'
|
? t('choreEdit.startWhen')
|
||||||
: 'When is the next first time this task is due?'}
|
: t('choreEdit.dueWhen')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='date'
|
type='date'
|
||||||
@@ -1471,19 +1479,19 @@ const ChoreEdit = () => {
|
|||||||
checked={useCustomTime}
|
checked={useCustomTime}
|
||||||
onChange={e => handleUseCustomTimeChange(e.target.checked)}
|
onChange={e => handleUseCustomTimeChange(e.target.checked)}
|
||||||
overlay
|
overlay
|
||||||
label='Set a specific time'
|
label={t('choreEdit.setSpecificTime')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
{useCustomTime
|
{useCustomTime
|
||||||
? 'Task will be due at the specified time'
|
? t('choreEdit.dueAtSpecifiedTime')
|
||||||
: 'Task will be due at the end of the day (11:59 PM)'}
|
: t('choreEdit.dueEndOfDay')}
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{useCustomTime && (
|
{useCustomTime && (
|
||||||
<Box sx={{ mt: 2, ml: 4 }}>
|
<Box sx={{ mt: 2, ml: 4 }}>
|
||||||
<Typography level='body-sm' mb={1}>
|
<Typography level='body-sm' mb={1}>
|
||||||
Time:
|
{t('choreEdit.timeLabel')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='time'
|
type='time'
|
||||||
@@ -1499,9 +1507,9 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Task Window</Typography>
|
<Typography level='h4'>{t('choreEdit.taskWindow')}</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Define when this task can be completed and when it expires
|
{t('choreEdit.taskWindowDesc')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Available From (Completion Window) */}
|
{/* Available From (Completion Window) */}
|
||||||
@@ -1516,10 +1524,10 @@ const ChoreEdit = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
overlay
|
overlay
|
||||||
label='Set earliest completion time'
|
label={t('choreEdit.earliestCompletion')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
Task becomes available to complete X hours before the due date
|
{t('choreEdit.earliestCompletionHelp')}
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -1531,7 +1539,9 @@ const ChoreEdit = () => {
|
|||||||
ml: 4,
|
ml: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body-sm'>Hours:</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('choreEdit.hoursLabel')}
|
||||||
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
type='number'
|
||||||
value={completionWindow}
|
value={completionWindow}
|
||||||
@@ -1542,7 +1552,7 @@ const ChoreEdit = () => {
|
|||||||
max: 24 * 7,
|
max: 24 * 7,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
placeholder='Hours'
|
placeholder={t('choreEdit.hoursPlaceholder')}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setCompletionWindow(parseInt(e.target.value))
|
setCompletionWindow(parseInt(e.target.value))
|
||||||
}}
|
}}
|
||||||
@@ -1589,7 +1599,9 @@ const ChoreEdit = () => {
|
|||||||
size='sm'
|
size='sm'
|
||||||
minValue={0}
|
minValue={0}
|
||||||
/>
|
/>
|
||||||
<Typography level='body-sm'>after due date</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('choreEdit.afterDueDate')}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1597,9 +1609,9 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
{!['once', 'no_repeat'].includes(frequencyType) && (
|
{!['once', 'no_repeat'].includes(frequencyType) && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='h4'>Scheduling Preferences</Typography>
|
<Typography level='h4'>{t('choreEdit.schedulingPrefs')}</Typography>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
How to reschedule the next due date?
|
{t('choreEdit.schedulingPrefsDesc')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<RadioGroup name='tiers' sx={{ gap: 1, '& > div': { p: 1 } }}>
|
<RadioGroup name='tiers' sx={{ gap: 1, '& > div': { p: 1 } }}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -1607,11 +1619,10 @@ const ChoreEdit = () => {
|
|||||||
overlay
|
overlay
|
||||||
checked={!isRolling}
|
checked={!isRolling}
|
||||||
onClick={() => setIsRolling(false)}
|
onClick={() => setIsRolling(false)}
|
||||||
label='Reschedule from due date'
|
label={t('choreEdit.rescheduleFromDue')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
the next task will be scheduled from the original due date,
|
{t('choreEdit.rescheduleFromDueHelp')}
|
||||||
even if the previous task was completed late
|
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -1622,11 +1633,10 @@ const ChoreEdit = () => {
|
|||||||
setIsRolling(true)
|
setIsRolling(true)
|
||||||
setDeadlineOffset(-1)
|
setDeadlineOffset(-1)
|
||||||
}}
|
}}
|
||||||
label='Reschedule from completion date'
|
label={t('choreEdit.rescheduleFromCompletion')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
the next task will be scheduled from the actual completion
|
{t('choreEdit.rescheduleFromCompletionHelp')}
|
||||||
date of the previous task
|
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
@@ -1635,11 +1645,10 @@ const ChoreEdit = () => {
|
|||||||
{/* Section 3.1: Notifications */}
|
{/* Section 3.1: Notifications */}
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Notifications</Typography>
|
<Typography level='h4'>{t('choreEdit.notifications')}</Typography>
|
||||||
{!isPlusAccount(userProfile) && (
|
{!isPlusAccount(userProfile) && (
|
||||||
<Typography level='body-sm' color='warning' sx={{ mb: 1 }}>
|
<Typography level='body-sm' color='warning' sx={{ mb: 1 }}>
|
||||||
Task notifications are not available in the Basic plan. Upgrade to
|
{t('choreEdit.notificationsPlanWarning')}
|
||||||
Plus to receive reminders when tasks are due or completed.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1655,14 +1664,14 @@ const ChoreEdit = () => {
|
|||||||
checked={isNotificable}
|
checked={isNotificable}
|
||||||
disabled={!isPlusAccount(userProfile)}
|
disabled={!isPlusAccount(userProfile)}
|
||||||
overlay
|
overlay
|
||||||
label='Notify for this task'
|
label={t('choreEdit.notifyForTask')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText
|
<FormHelperText
|
||||||
sx={{
|
sx={{
|
||||||
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
|
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
When should receive notifications for this task
|
{t('choreEdit.notifyForTaskHelp')}
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1677,7 +1686,7 @@ const ChoreEdit = () => {
|
|||||||
>
|
>
|
||||||
<Card variant='outlined'>
|
<Card variant='outlined'>
|
||||||
<Typography level='h4' mb={2}>
|
<Typography level='h4' mb={2}>
|
||||||
Notification Schedule
|
{t('choreEdit.notificationSchedule')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ p: 0.5 }}>
|
<Box sx={{ p: 0.5 }}>
|
||||||
<NotificationTemplate
|
<NotificationTemplate
|
||||||
@@ -1695,16 +1704,18 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography level='h4' mt={3} mb={2}>
|
<Typography level='h4' mt={3} mb={2}>
|
||||||
Who to Notify
|
{t('choreEdit.whoToNotify')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
overlay
|
overlay
|
||||||
disabled={true}
|
disabled={true}
|
||||||
checked={true}
|
checked={true}
|
||||||
label='All Assignees'
|
label={t('choreEdit.allAssignees')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>Notify all assignees</FormHelperText>
|
<FormHelperText>
|
||||||
|
{t('choreEdit.allAssigneesHelp')}
|
||||||
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -1725,9 +1736,11 @@ const ChoreEdit = () => {
|
|||||||
? notificationMetadata?.circleGroup
|
? notificationMetadata?.circleGroup
|
||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
label='Specific Group'
|
label={t('choreEdit.specificGroup')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>Notify a specific group</FormHelperText>
|
<FormHelperText>
|
||||||
|
{t('choreEdit.specificGroupHelp')}
|
||||||
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{notificationMetadata?.circleGroup && (
|
{notificationMetadata?.circleGroup && (
|
||||||
@@ -1737,11 +1750,13 @@ const ChoreEdit = () => {
|
|||||||
ml: 4,
|
ml: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body-sm'>Telegram Group ID:</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('choreEdit.telegramGroupIdLabel')}
|
||||||
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
type='number'
|
||||||
value={notificationMetadata?.circleGroupID}
|
value={notificationMetadata?.circleGroupID}
|
||||||
placeholder='Telegram Group ID'
|
placeholder={t('choreEdit.telegramGroupIdPlaceholder')}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setNotificationMetadata({
|
setNotificationMetadata({
|
||||||
...notificationMetadata,
|
...notificationMetadata,
|
||||||
@@ -1766,11 +1781,11 @@ const ChoreEdit = () => {
|
|||||||
pb: 1,
|
pb: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Task Settings:
|
{t('choreEdit.taskSettings')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Points System</Typography>
|
<Typography level='h4'>{t('choreEdit.pointsSystem')}</Typography>
|
||||||
<FormControl sx={{ mt: 1 }}>
|
<FormControl sx={{ mt: 1 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@@ -1782,12 +1797,9 @@ const ChoreEdit = () => {
|
|||||||
}}
|
}}
|
||||||
checked={points > -1}
|
checked={points > -1}
|
||||||
overlay
|
overlay
|
||||||
label='Assign points for completion'
|
label={t('choreEdit.assignPoints')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>{t('choreEdit.assignPointsHelp')}</FormHelperText>
|
||||||
Assign points to this task and user will earn points when they
|
|
||||||
completed it
|
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{points != -1 && (
|
{points != -1 && (
|
||||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||||
@@ -1797,7 +1809,9 @@ const ChoreEdit = () => {
|
|||||||
ml: 4,
|
ml: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body-sm'>Points:</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('choreEdit.pointsLabel')}
|
||||||
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
type='number'
|
||||||
value={points}
|
value={points}
|
||||||
@@ -1808,7 +1822,7 @@ const ChoreEdit = () => {
|
|||||||
max: 1000,
|
max: 1000,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
placeholder='Points'
|
placeholder={t('choreEdit.pointsPlaceholder')}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setPoints(parseInt(e.target.value))
|
setPoints(parseInt(e.target.value))
|
||||||
}}
|
}}
|
||||||
@@ -1819,7 +1833,9 @@ const ChoreEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Approval Requirement</Typography>
|
<Typography level='h4'>
|
||||||
|
{t('choreEdit.approvalRequirement')}
|
||||||
|
</Typography>
|
||||||
<FormControl sx={{ mt: 1 }}>
|
<FormControl sx={{ mt: 1 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@@ -1827,18 +1843,17 @@ const ChoreEdit = () => {
|
|||||||
}}
|
}}
|
||||||
checked={requireApproval}
|
checked={requireApproval}
|
||||||
overlay
|
overlay
|
||||||
label='Require admin approval'
|
label={t('choreEdit.requireApproval')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
This task will need approval from an admin before being marked as
|
{t('choreEdit.requireApprovalHelp')}
|
||||||
complete
|
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='h4'>Privacy Settings</Typography>
|
<Typography level='h4'>{t('choreEdit.privacySettings')}</Typography>
|
||||||
<Typography level='body-md'>Who can see this task?</Typography>
|
<Typography level='body-md'>{t('choreEdit.privacyDesc')}</Typography>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
name='isPrivate'
|
name='isPrivate'
|
||||||
value={isPrivate}
|
value={isPrivate}
|
||||||
@@ -1852,20 +1867,20 @@ const ChoreEdit = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Radio overlay value={false} label='Public' />
|
<Radio overlay value={false} label={t('choreEdit.public')} />
|
||||||
<FormHelperText>Everyone in your circle</FormHelperText>
|
<FormHelperText>{t('choreEdit.publicHelp')}</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Radio
|
<Radio
|
||||||
overlay
|
overlay
|
||||||
disabled={anyone || assignableTo.length === 0}
|
disabled={anyone || assignableTo.length === 0}
|
||||||
value={true}
|
value={true}
|
||||||
label='Limited'
|
label={t('choreEdit.limited')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
You and others that are assigned to the task
|
{t('choreEdit.limitedHelp')}
|
||||||
{anyone || assignableTo.length === 0
|
{anyone || assignableTo.length === 0
|
||||||
? ' (No assignees selected, Limited option is disabled)'
|
? t('choreEdit.limitedDisabledHint')
|
||||||
: ''}
|
: ''}
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -1893,7 +1908,7 @@ const ChoreEdit = () => {
|
|||||||
setShowSavePrivacyDefault(false)
|
setShowSavePrivacyDefault(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remember for Future Tasks
|
{t('choreEdit.rememberFuture')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -1910,7 +1925,7 @@ const ChoreEdit = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body1'>
|
<Typography level='body1'>
|
||||||
Created by{' '}
|
{t('choreEdit.createdBy')}{' '}
|
||||||
<Chip variant='solid'>
|
<Chip variant='solid'>
|
||||||
{membersData.res.find(f => f.userId === createdBy)?.displayName}
|
{membersData.res.find(f => f.userId === createdBy)?.displayName}
|
||||||
</Chip>{' '}
|
</Chip>{' '}
|
||||||
@@ -1921,7 +1936,7 @@ const ChoreEdit = () => {
|
|||||||
<Divider sx={{ my: 1 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
<Typography level='body1'>
|
<Typography level='body1'>
|
||||||
Updated by{' '}
|
{t('choreEdit.updatedBy')}{' '}
|
||||||
<Chip variant='solid'>
|
<Chip variant='solid'>
|
||||||
{
|
{
|
||||||
membersData.res.find(f => f.userId === updatedBy)
|
membersData.res.find(f => f.userId === updatedBy)
|
||||||
@@ -1971,7 +1986,7 @@ const ChoreEdit = () => {
|
|||||||
: unarchiveChore.mutate(choreId)
|
: unarchiveChore.mutate(choreId)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isActive ? 'Archive' : 'Unarchive'}
|
{isActive ? t('choreEdit.archive') : t('choreEdit.unarchive')}
|
||||||
</Button>
|
</Button>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
slots={{ root: IconButton }}
|
slots={{ root: IconButton }}
|
||||||
@@ -1987,7 +2002,7 @@ const ChoreEdit = () => {
|
|||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<Menu placement='top-end'>
|
<Menu placement='top-end'>
|
||||||
<MenuItem color='danger' onClick={handleDelete}>
|
<MenuItem color='danger' onClick={handleDelete}>
|
||||||
Delete
|
{t('common:delete')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -1999,13 +2014,13 @@ const ChoreEdit = () => {
|
|||||||
window.history.back()
|
window.history.back()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:cancel')}
|
||||||
{showKeyboardShortcuts && (
|
{showKeyboardShortcuts && (
|
||||||
<KeyboardShortcutHint shortcut='Esc' sx={{ ml: 1 }} />
|
<KeyboardShortcutHint shortcut='Esc' sx={{ ml: 1 }} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color='primary' variant='solid' onClick={HandleSaveChore}>
|
<Button color='primary' variant='solid' onClick={HandleSaveChore}>
|
||||||
{choreId > 0 ? 'Save' : 'Create'}
|
{choreId > 0 ? t('common:save') : t('choreEdit.create')}
|
||||||
{showKeyboardShortcuts && (
|
{showKeyboardShortcuts && (
|
||||||
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
@@ -33,11 +34,7 @@ const FREQUENCY_TYPES_RADIOS = [
|
|||||||
'custom',
|
'custom',
|
||||||
]
|
]
|
||||||
|
|
||||||
const FREQUENCY_TYPE_MESSAGE = {
|
const FREQUENCY_TYPE_WITH_MESSAGE = ['adaptive', 'custom']
|
||||||
adaptive:
|
|
||||||
'This chore will be scheduled dynamically based on previous completion dates.',
|
|
||||||
custom: 'This chore will be scheduled based on a custom frequency.',
|
|
||||||
}
|
|
||||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
'january',
|
'january',
|
||||||
@@ -64,46 +61,40 @@ const DAYS = [
|
|||||||
'sunday',
|
'sunday',
|
||||||
]
|
]
|
||||||
|
|
||||||
const WEEK_PATTERNS = {
|
const WEEK_PATTERNS = ['every_week', 'week_of_month']
|
||||||
every_week: 'Every week',
|
|
||||||
week_of_month: 'Specific occurrences in the 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
|
// Helper function to generate schedule preview text
|
||||||
const generateSchedulePreview = (metadata, formatTimeFn) => {
|
const generateSchedulePreview = (metadata, formatTimeFn, t) => {
|
||||||
if (!metadata?.days?.length) return ''
|
if (!metadata?.days?.length) return ''
|
||||||
|
|
||||||
const dayNames = metadata.days
|
const dayNames = metadata.days
|
||||||
.map(day => day.charAt(0).toUpperCase() + day.slice(1, 3))
|
.map(day => t(`repeat.daysShort.${day}`))
|
||||||
.join(', ')
|
.join(', ')
|
||||||
|
|
||||||
const timeStr = metadata.time ? formatTimeFn(metadata.time) : '6:00 PM'
|
const timeStr = metadata.time
|
||||||
|
? formatTimeFn(metadata.time)
|
||||||
if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
|
: t('repeat.defaultTime')
|
||||||
return `Every ${dayNames} at ${timeStr}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
metadata.weekPattern === 'week_of_month' &&
|
metadata.weekPattern === 'week_of_month' &&
|
||||||
metadata.occurrences?.length
|
metadata.occurrences?.length
|
||||||
) {
|
) {
|
||||||
const occurrenceStr = metadata.occurrences
|
const occurrenceStr = metadata.occurrences
|
||||||
.map(w => {
|
.map(w => t(`repeat.ordinal.${occurrenceKey(w)}`))
|
||||||
if (w === -1) return 'last'
|
|
||||||
return `${w}${w === 1 ? 'st' : w === 2 ? 'nd' : w === 3 ? 'rd' : 'th'}`
|
|
||||||
})
|
|
||||||
.join(', ')
|
.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 = ({
|
export const RepeatOnSections = ({
|
||||||
@@ -114,6 +105,7 @@ export const RepeatOnSections = ({
|
|||||||
onFrequencyUpdate,
|
onFrequencyUpdate,
|
||||||
}) => {
|
}) => {
|
||||||
const { fmt } = useLocalization()
|
const { fmt } = useLocalization()
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
|
// 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
|
// otherwise set it to 18:00 of the current day
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -142,7 +134,7 @@ export const RepeatOnSections = ({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='h5'>Time of day: </Typography>
|
<Typography level='h5'>{t('repeat.timeOfDay')}</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='time'
|
type='time'
|
||||||
sx={{ width: '150px' }}
|
sx={{ width: '150px' }}
|
||||||
@@ -165,7 +157,7 @@ export const RepeatOnSections = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
|
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Typography level='h5'>Every: </Typography>
|
<Typography level='h5'>{t('repeat.every')}</Typography>
|
||||||
<Input
|
<Input
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
@@ -180,7 +172,7 @@ export const RepeatOnSections = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder='Unit'
|
placeholder={t('repeat.unitPlaceholder')}
|
||||||
value={frequencyMetadata?.unit || 'days'}
|
value={frequencyMetadata?.unit || 'days'}
|
||||||
sx={{ ml: 1 }}
|
sx={{ ml: 1 }}
|
||||||
>
|
>
|
||||||
@@ -195,7 +187,7 @@ export const RepeatOnSections = ({
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
{t(`repeat.unit.${item}`)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -239,7 +231,7 @@ export const RepeatOnSections = ({
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={item.charAt(0).toUpperCase() + item.slice(1)}
|
label={t(`repeat.days.${item}`)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -268,8 +260,8 @@ export const RepeatOnSections = ({
|
|||||||
disableIcon
|
disableIcon
|
||||||
>
|
>
|
||||||
{frequencyMetadata?.days?.length === 7
|
{frequencyMetadata?.days?.length === 7
|
||||||
? 'Unselect All'
|
? t('repeat.unselectAll')
|
||||||
: 'Select All'}
|
: t('repeat.selectAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -291,20 +283,16 @@ export const RepeatOnSections = ({
|
|||||||
}}
|
}}
|
||||||
sx={{ gap: 1, '& > div': { p: 1 } }}
|
sx={{ gap: 1, '& > div': { p: 1 } }}
|
||||||
>
|
>
|
||||||
{Object.entries(WEEK_PATTERNS).map(([value, label]) => (
|
{WEEK_PATTERNS.map(value => (
|
||||||
<FormControl key={value}>
|
<FormControl key={value}>
|
||||||
<Radio value={value} label={label} variant='soft' />
|
<Radio
|
||||||
{value === 'every_week' && (
|
value={value}
|
||||||
<FormHelperText>
|
label={t(`repeat.weekPattern.${value}`)}
|
||||||
Task repeats every week on selected days
|
variant='soft'
|
||||||
</FormHelperText>
|
/>
|
||||||
)}
|
<FormHelperText>
|
||||||
{value === 'week_of_month' && (
|
{t(`repeat.weekPatternHelp.${value}`)}
|
||||||
<FormHelperText>
|
</FormHelperText>
|
||||||
Task repeats on specific day occurrences each month
|
|
||||||
(e.g., 1st Monday, 3rd Friday)
|
|
||||||
</FormHelperText>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
@@ -312,10 +300,10 @@ export const RepeatOnSections = ({
|
|||||||
{frequencyMetadata?.weekPattern === 'week_of_month' && (
|
{frequencyMetadata?.weekPattern === 'week_of_month' && (
|
||||||
<Box mt={2}>
|
<Box mt={2}>
|
||||||
<Typography level='body-sm' mb={1}>
|
<Typography level='body-sm' mb={1}>
|
||||||
Select which occurrences of the selected days:
|
{t('repeat.selectOccurrences')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-xs' color='neutral' mb={2}>
|
<Typography level='body-xs' color='neutral' mb={2}>
|
||||||
Example: "1st Monday" means the first Monday of each month
|
{t('repeat.occurrenceExample')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Card>
|
<Card>
|
||||||
<List
|
<List
|
||||||
@@ -326,23 +314,21 @@ export const RepeatOnSections = ({
|
|||||||
'--ListItem-radius': '20px',
|
'--ListItem-radius': '20px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{DAY_OCCURRENCE_OPTIONS.map(option => (
|
{DAY_OCCURRENCE_VALUES.map(option => (
|
||||||
<ListItem key={option.value}>
|
<ListItem key={option}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={
|
||||||
frequencyMetadata?.occurrences?.includes(
|
frequencyMetadata?.occurrences?.includes(
|
||||||
option.value,
|
option,
|
||||||
) || false
|
) || false
|
||||||
}
|
}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
const currentOccurrences =
|
const currentOccurrences =
|
||||||
frequencyMetadata?.occurrences || []
|
frequencyMetadata?.occurrences || []
|
||||||
const newOccurrences =
|
const newOccurrences =
|
||||||
currentOccurrences.includes(option.value)
|
currentOccurrences.includes(option)
|
||||||
? currentOccurrences.filter(
|
? currentOccurrences.filter(w => w !== option)
|
||||||
w => w !== option.value,
|
: [...currentOccurrences, option]
|
||||||
)
|
|
||||||
: [...currentOccurrences, option.value]
|
|
||||||
onFrequencyMetadataUpdate({
|
onFrequencyMetadataUpdate({
|
||||||
...frequencyMetadata,
|
...frequencyMetadata,
|
||||||
occurrences: newOccurrences.sort((a, b) => {
|
occurrences: newOccurrences.sort((a, b) => {
|
||||||
@@ -355,7 +341,9 @@ export const RepeatOnSections = ({
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={option.label}
|
label={t(
|
||||||
|
`repeat.occurrence.${occurrenceKey(option)}`,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -367,7 +355,7 @@ export const RepeatOnSections = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
frequencyMetadata?.occurrences?.length ===
|
frequencyMetadata?.occurrences?.length ===
|
||||||
DAY_OCCURRENCE_OPTIONS.length
|
DAY_OCCURRENCE_VALUES.length
|
||||||
) {
|
) {
|
||||||
onFrequencyMetadataUpdate({
|
onFrequencyMetadataUpdate({
|
||||||
...frequencyMetadata,
|
...frequencyMetadata,
|
||||||
@@ -376,9 +364,7 @@ export const RepeatOnSections = ({
|
|||||||
} else {
|
} else {
|
||||||
onFrequencyMetadataUpdate({
|
onFrequencyMetadataUpdate({
|
||||||
...frequencyMetadata,
|
...frequencyMetadata,
|
||||||
occurrences: DAY_OCCURRENCE_OPTIONS.map(
|
occurrences: [...DAY_OCCURRENCE_VALUES],
|
||||||
option => option.value,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -386,9 +372,9 @@ export const RepeatOnSections = ({
|
|||||||
disableIcon
|
disableIcon
|
||||||
>
|
>
|
||||||
{frequencyMetadata?.occurrences?.length ===
|
{frequencyMetadata?.occurrences?.length ===
|
||||||
DAY_OCCURRENCE_OPTIONS.length
|
DAY_OCCURRENCE_VALUES.length
|
||||||
? 'Unselect All'
|
? t('repeat.unselectAll')
|
||||||
: 'Select All'}
|
: t('repeat.selectAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -400,7 +386,7 @@ export const RepeatOnSections = ({
|
|||||||
{frequencyMetadata?.days?.length > 0 && (
|
{frequencyMetadata?.days?.length > 0 && (
|
||||||
<Card mt={2} p={2}>
|
<Card mt={2} p={2}>
|
||||||
<Typography level='body-sm' color='primary'>
|
<Typography level='body-sm' color='primary'>
|
||||||
{generateSchedulePreview(frequencyMetadata, fmt.time)}
|
{generateSchedulePreview(frequencyMetadata, fmt.time, t)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -458,7 +444,7 @@ export const RepeatOnSections = ({
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={item.charAt(0).toUpperCase() + item.slice(1)}
|
label={t(`repeat.months.${item}`)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -485,8 +471,8 @@ export const RepeatOnSections = ({
|
|||||||
disableIcon
|
disableIcon
|
||||||
>
|
>
|
||||||
{frequencyMetadata?.months?.length === 12
|
{frequencyMetadata?.months?.length === 12
|
||||||
? 'Unselect All'
|
? t('repeat.unselectAll')
|
||||||
: 'Select All'}
|
: t('repeat.selectAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -497,7 +483,7 @@ export const RepeatOnSections = ({
|
|||||||
mb: 1.5,
|
mb: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography>on the </Typography>
|
<Typography>{t('repeat.onThe')}</Typography>
|
||||||
<Input
|
<Input
|
||||||
sx={{ width: '80px' }}
|
sx={{ width: '80px' }}
|
||||||
type='number'
|
type='number'
|
||||||
@@ -513,7 +499,7 @@ export const RepeatOnSections = ({
|
|||||||
onFrequencyUpdate(e.target.value)
|
onFrequencyUpdate(e.target.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography>of the above month/s</Typography>
|
<Typography>{t('repeat.ofAboveMonths')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{timePickerComponent}
|
{timePickerComponent}
|
||||||
</>
|
</>
|
||||||
@@ -540,10 +526,11 @@ const RepeatSection = ({
|
|||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
|
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box mt={2}>
|
<Box mt={2}>
|
||||||
<Typography level='h4'>Repeat:</Typography>
|
<Typography level='h4'>{t('repeat.title')}</Typography>
|
||||||
<FormControl sx={{ mt: 1 }}>
|
<FormControl sx={{ mt: 1 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@@ -556,16 +543,14 @@ const RepeatSection = ({
|
|||||||
checked={!['once', 'trigger'].includes(frequencyType)}
|
checked={!['once', 'trigger'].includes(frequencyType)}
|
||||||
value={!['once', 'trigger'].includes(frequencyType)}
|
value={!['once', 'trigger'].includes(frequencyType)}
|
||||||
overlay
|
overlay
|
||||||
label='Repeat this task'
|
label={t('repeat.repeatThisTask')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>{t('repeat.repeatThisTaskHelp')}</FormHelperText>
|
||||||
Is this something needed to be done regularly?
|
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{!['once', 'trigger'].includes(frequencyType) && (
|
{!['once', 'trigger'].includes(frequencyType) && (
|
||||||
<>
|
<>
|
||||||
<Card sx={{ mt: 1 }}>
|
<Card sx={{ mt: 1 }}>
|
||||||
<Typography level='h5'>How often should it be repeated?</Typography>
|
<Typography level='h5'>{t('repeat.howOften')}</Typography>
|
||||||
|
|
||||||
<List
|
<List
|
||||||
orientation='horizontal'
|
orientation='horizontal'
|
||||||
@@ -609,21 +594,22 @@ const RepeatSection = ({
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={
|
label={t(`repeat.freqType.${item}`)}
|
||||||
item.charAt(0).toUpperCase() +
|
|
||||||
item.slice(1).replace('_', ' ')
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
<Typography>{FREQUENCY_TYPE_MESSAGE[frequencyType]}</Typography>
|
<Typography>
|
||||||
|
{FREQUENCY_TYPE_WITH_MESSAGE.includes(frequencyType)
|
||||||
|
? t(`repeat.freqMessage.${frequencyType}`)
|
||||||
|
: ''}
|
||||||
|
</Typography>
|
||||||
{frequencyType === 'custom' ||
|
{frequencyType === 'custom' ||
|
||||||
(REPEAT_ON_TYPE.includes(frequencyType) && (
|
(REPEAT_ON_TYPE.includes(frequencyType) && (
|
||||||
<>
|
<>
|
||||||
<Grid container spacing={1} mt={2}>
|
<Grid container spacing={1} mt={2}>
|
||||||
<Grid item>
|
<Grid item>
|
||||||
<Typography>Repeat on:</Typography>
|
<Typography>{t('repeat.repeatOn')}</Typography>
|
||||||
<Box
|
<Box
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||||
>
|
>
|
||||||
@@ -678,21 +664,7 @@ const RepeatSection = ({
|
|||||||
}}
|
}}
|
||||||
value={item}
|
value={item}
|
||||||
disableIcon
|
disableIcon
|
||||||
label={item
|
label={t(`repeat.repeatOnType.${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(' ')}
|
|
||||||
variant='plain'
|
variant='plain'
|
||||||
sx={{
|
sx={{
|
||||||
px: 2,
|
px: 2,
|
||||||
@@ -749,24 +721,23 @@ const RepeatSection = ({
|
|||||||
value={frequencyType === 'trigger'}
|
value={frequencyType === 'trigger'}
|
||||||
disabled={!isPlusAccount(userProfile)}
|
disabled={!isPlusAccount(userProfile)}
|
||||||
overlay
|
overlay
|
||||||
label='Trigger this task based on a thing state'
|
label={t('repeat.triggerLabel')}
|
||||||
/>
|
/>
|
||||||
<FormHelperText
|
<FormHelperText
|
||||||
sx={{
|
sx={{
|
||||||
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
|
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Is this something that should be done when a thing state changes?{' '}
|
{t('repeat.triggerHelp')}{' '}
|
||||||
{userProfile && !isPlusAccount(userProfile) && (
|
{userProfile && !isPlusAccount(userProfile) && (
|
||||||
<Chip variant='soft' color='warning'>
|
<Chip variant='soft' color='warning'>
|
||||||
Plus Feature
|
{t('settings:common.plusFeature')}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
{!isPlusAccount(userProfile) && (
|
{!isPlusAccount(userProfile) && (
|
||||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||||
Thing-based triggers are not available in the Basic plan. Upgrade to
|
{t('repeat.triggerPlanWarning')}
|
||||||
Plus to automatically trigger tasks when device states change.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ const ArchivedTasks = () => {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsFilterSheetOpen(true)}
|
onClick={() => setIsFilterSheetOpen(true)}
|
||||||
title='Filters'
|
title={t('archived.filtersTitle')}
|
||||||
>
|
>
|
||||||
<FilterList />
|
<FilterList />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -774,10 +774,10 @@ const ArchivedTasks = () => {
|
|||||||
{/* Sort Menu */}
|
{/* Sort Menu */}
|
||||||
<SortAndFilterMenu
|
<SortAndFilterMenu
|
||||||
sortOptions={[
|
sortOptions={[
|
||||||
{ name: 'Archived date', value: 'archivedAt' },
|
{ name: t('archived.sortArchivedDate'), value: 'archivedAt' },
|
||||||
{ name: 'Name', value: 'name' },
|
{ name: t('archived.sortName'), value: 'name' },
|
||||||
{ name: 'Priority', value: 'priority' },
|
{ name: t('archived.sortPriority'), value: 'priority' },
|
||||||
{ name: 'Due date', value: 'dueDate' },
|
{ name: t('archived.sortDueDate'), value: 'dueDate' },
|
||||||
]}
|
]}
|
||||||
selectedSort={sortBy}
|
selectedSort={sortBy}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
@@ -1045,11 +1045,11 @@ const ArchivedTasks = () => {
|
|||||||
variant='no-results'
|
variant='no-results'
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<SearchOff />}
|
icon={<SearchOff />}
|
||||||
title='No archived tasks match'
|
title={t('archived.noMatchTitle')}
|
||||||
description={
|
description={
|
||||||
searchTerm
|
searchTerm
|
||||||
? `Nothing in the archive matches "${searchTerm}".`
|
? t('archived.noMatchSearch', { term: searchTerm })
|
||||||
: 'There are archived tasks, but none fit the filters that are currently on.'
|
: t('archived.noMatchFilters')
|
||||||
}
|
}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
searchTerm
|
searchTerm
|
||||||
@@ -1069,9 +1069,9 @@ const ArchivedTasks = () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<Archive />}
|
icon={<Archive />}
|
||||||
title='Nothing archived'
|
title={t('archived.emptyTitle')}
|
||||||
description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
|
description={t('archived.emptyDescription')}
|
||||||
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
primaryAction={{ label: t('archived.backToTasks'), to: '/chores' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ const ChoreListView = ({
|
|||||||
>
|
>
|
||||||
<ThumbDown sx={{ fontSize: 20 }} />
|
<ThumbDown sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Reject
|
{t('list.reject')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
@@ -173,7 +173,7 @@ const ChoreListView = ({
|
|||||||
>
|
>
|
||||||
<HourglassEmpty sx={{ fontSize: 20 }} />
|
<HourglassEmpty sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Pending
|
{t('list.pending')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
@@ -231,7 +231,7 @@ const ChoreListView = ({
|
|||||||
>
|
>
|
||||||
<Schedule sx={{ fontSize: 20 }} />
|
<Schedule sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Schedule
|
{t('list.schedule')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
@@ -251,7 +251,7 @@ const ChoreListView = ({
|
|||||||
>
|
>
|
||||||
<Edit sx={{ fontSize: 20 }} />
|
<Edit sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Edit
|
{t('common:edit')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
@@ -272,7 +272,7 @@ const ChoreListView = ({
|
|||||||
>
|
>
|
||||||
<Notifications sx={{ fontSize: 20 }} />
|
<Notifications sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Nudge
|
{t('list.nudge')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
@@ -293,7 +293,7 @@ const ChoreListView = ({
|
|||||||
>
|
>
|
||||||
<Delete sx={{ fontSize: 20 }} />
|
<Delete sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Delete
|
{t('common:delete')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
|
|||||||
@@ -77,13 +77,17 @@ import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
|||||||
|
|
||||||
// Mirrors the assignee options in the toolbar, phrased to drop into a
|
// Mirrors the assignee options in the toolbar, phrased to drop into a
|
||||||
// sentence ("none of them are assigned to you").
|
// sentence ("none of them are assigned to you").
|
||||||
const ASSIGNEE_FILTER_LABELS = {
|
// Which assignee filters describe a subset of "everyone" — the empty-state
|
||||||
assigned_to_me: 'assigned to you',
|
// copy for each lives under `chores:empty.assignee.*` as a whole sentence,
|
||||||
available_for_me: 'available for you to pick up',
|
// since the fragment ("assigned to you") cannot be slotted mid-sentence in
|
||||||
assigned_to_others: 'assigned to someone else',
|
// every language.
|
||||||
assigned_to_me_tasks: 'assigned to you',
|
const ASSIGNEE_FILTER_KEYS = [
|
||||||
created_by_me: 'created by you',
|
'assigned_to_me',
|
||||||
}
|
'available_for_me',
|
||||||
|
'assigned_to_others',
|
||||||
|
'assigned_to_me_tasks',
|
||||||
|
'created_by_me',
|
||||||
|
]
|
||||||
|
|
||||||
const MyChores = () => {
|
const MyChores = () => {
|
||||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||||
@@ -899,7 +903,8 @@ const MyChores = () => {
|
|||||||
// The assignee filter ("Mine", "Available to me", ...) is applied inside
|
// The assignee filter ("Mine", "Available to me", ...) is applied inside
|
||||||
// ChoresGrouper, not in projectFilteredChores, so it can hide every task
|
// ChoresGrouper, not in projectFilteredChores, so it can hide every task
|
||||||
// while the unfiltered list still looks full. It narrows like any other.
|
// 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(
|
const hasAssigneeFilter = Boolean(
|
||||||
selectedChoreFilter && selectedChoreFilter !== 'anyone',
|
selectedChoreFilter && selectedChoreFilter !== 'anyone',
|
||||||
)
|
)
|
||||||
@@ -919,7 +924,7 @@ const MyChores = () => {
|
|||||||
// Worth its own wording: the assignee filter is the one narrowing that is
|
// 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".
|
// easy to forget you left on, so name it rather than saying "filters".
|
||||||
const isAssigneeOnlyNarrowing = Boolean(
|
const isAssigneeOnlyNarrowing = Boolean(
|
||||||
assigneeFilterLabel &&
|
hasAssigneeNarrowing &&
|
||||||
!searchTerm?.length &&
|
!searchTerm?.length &&
|
||||||
!hasQuickFilters &&
|
!hasQuickFilters &&
|
||||||
!activeFilterId,
|
!activeFilterId,
|
||||||
@@ -973,13 +978,12 @@ const MyChores = () => {
|
|||||||
variant='error'
|
variant='error'
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<CloudOff />}
|
icon={<CloudOff />}
|
||||||
title={"Can't reach Donetick"}
|
title={t('empty.errorTitle')}
|
||||||
description={
|
description={
|
||||||
choresErrorDetails?.message ||
|
choresErrorDetails?.message || t('empty.errorDescription')
|
||||||
'Your tasks are safe. We just could not load them right now, check your connection and try again.'
|
|
||||||
}
|
}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: 'Try again',
|
label: t('empty.errorAction'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
refetchChores()
|
refetchChores()
|
||||||
queryClient.invalidateQueries(['circleMembers'])
|
queryClient.invalidateQueries(['circleMembers'])
|
||||||
@@ -1163,15 +1167,15 @@ const MyChores = () => {
|
|||||||
variant='empty'
|
variant='empty'
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<EditCalendar />}
|
icon={<EditCalendar />}
|
||||||
title='No tasks yet'
|
title={t('empty.noTasksTitle')}
|
||||||
description='Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.'
|
description={t('empty.noTasksDescription')}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: 'Create a task',
|
label: t('empty.createTask'),
|
||||||
startDecorator: <Add />,
|
startDecorator: <Add />,
|
||||||
onClick: () => setAddTaskModalOpen(true),
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
}}
|
}}
|
||||||
secondaryAction={{
|
secondaryAction={{
|
||||||
label: 'More options',
|
label: t('empty.moreOptions'),
|
||||||
onClick: () => Navigate('/chores/create'),
|
onClick: () => Navigate('/chores/create'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1183,21 +1187,21 @@ const MyChores = () => {
|
|||||||
variant='no-results'
|
variant='no-results'
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<SearchOff />}
|
icon={<SearchOff />}
|
||||||
title='No tasks match this view'
|
title={t('empty.noMatchTitle')}
|
||||||
description={
|
description={
|
||||||
searchTerm?.length > 0
|
searchTerm?.length > 0
|
||||||
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
|
? t('empty.noMatchSearch', { term: searchTerm })
|
||||||
: isAssigneeOnlyNarrowing
|
: isAssigneeOnlyNarrowing
|
||||||
? `There are tasks here, but none of them are ${assigneeFilterLabel}. Switch back to everyone to see the rest.`
|
? t(`empty.assignee.${selectedChoreFilter}`)
|
||||||
: 'You have tasks, but none of them fit the filters that are currently on.'
|
: t('empty.noMatchFilters')
|
||||||
}
|
}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label:
|
label:
|
||||||
searchTerm?.length > 0
|
searchTerm?.length > 0
|
||||||
? 'Clear search'
|
? t('empty.clearSearch')
|
||||||
: isAssigneeOnlyNarrowing
|
: isAssigneeOnlyNarrowing
|
||||||
? "Show everyone's tasks"
|
? t('empty.showEveryone')
|
||||||
: 'Clear filters',
|
: t('empty.clearFilters'),
|
||||||
onClick: clearNarrowing,
|
onClick: clearNarrowing,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1206,15 +1210,15 @@ const MyChores = () => {
|
|||||||
variant='empty'
|
variant='empty'
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<EditCalendar />}
|
icon={<EditCalendar />}
|
||||||
title={`Nothing in ${selectedProject.name} yet`}
|
title={t('empty.projectTitle', { name: selectedProject.name })}
|
||||||
description='Tasks you add to this project show up here. Your other tasks are still where you left them.'
|
description={t('empty.projectDescription')}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: 'Add a task here',
|
label: t('empty.addTaskHere'),
|
||||||
startDecorator: <Add />,
|
startDecorator: <Add />,
|
||||||
onClick: () => setAddTaskModalOpen(true),
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
}}
|
}}
|
||||||
secondaryAction={{
|
secondaryAction={{
|
||||||
label: 'See tasks outside projects',
|
label: t('empty.seeOutsideProjects'),
|
||||||
onClick: () => setSelectedProjectWithCache(null),
|
onClick: () => setSelectedProjectWithCache(null),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1223,10 +1227,10 @@ const MyChores = () => {
|
|||||||
variant='empty'
|
variant='empty'
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<EditCalendar />}
|
icon={<EditCalendar />}
|
||||||
title='No tasks here yet'
|
title={t('empty.noProjectTitle')}
|
||||||
description='Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.'
|
description={t('empty.noProjectDescription')}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: 'Create a task',
|
label: t('empty.createTask'),
|
||||||
startDecorator: <Add />,
|
startDecorator: <Add />,
|
||||||
onClick: () => setAddTaskModalOpen(true),
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
}}
|
}}
|
||||||
@@ -1404,9 +1408,9 @@ const MyChores = () => {
|
|||||||
size='sm'
|
size='sm'
|
||||||
icon={<EditCalendar />}
|
icon={<EditCalendar />}
|
||||||
title={t('list.nothingScheduled')}
|
title={t('list.nothingScheduled')}
|
||||||
description='This day is free. Add a task if you want something to land here.'
|
description={t('empty.dayFreeDescription')}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: 'Add task',
|
label: t('empty.addTask'),
|
||||||
startDecorator: <Add />,
|
startDecorator: <Add />,
|
||||||
onClick: () => setAddTaskModalOpen(true),
|
onClick: () => setAddTaskModalOpen(true),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -252,23 +252,27 @@ const ChoreToolbar = ({
|
|||||||
if (!condition?.type) return 'Filter'
|
if (!condition?.type) return 'Filter'
|
||||||
|
|
||||||
const typeLabels = {
|
const typeLabels = {
|
||||||
assignee: 'Assignee',
|
assignee: t('filterBuilder.assignee'),
|
||||||
createdBy: 'Created By',
|
createdBy: t('filterBuilder.createdBy'),
|
||||||
status: 'Status',
|
status: t('filterBuilder.statusHeading'),
|
||||||
priority: 'Priority',
|
priority: t('filterBuilder.priority'),
|
||||||
label: t('labels.label'),
|
label: t('labels.label'),
|
||||||
project: 'Project',
|
project: t('filterBuilder.project'),
|
||||||
dueDate: 'Due Date',
|
dueDate: t('filterBuilder.dueDate'),
|
||||||
points: 'Points',
|
points: t('filterBuilder.points'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeLabel = typeLabels[condition.type] || 'Filter'
|
const typeLabel =
|
||||||
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
|
typeLabels[condition.type] || t('filterBuilder.filterFallback')
|
||||||
|
const prefix =
|
||||||
|
condition.operator === 'isNot' ? t('filterBuilder.notPrefix') : ''
|
||||||
|
|
||||||
if (condition.type === 'dueDate') {
|
if (condition.type === 'dueDate') {
|
||||||
const dueDateLabel =
|
const dueDateLabel = DUE_DATE_OPTIONS.some(
|
||||||
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
|
o => o.value === condition.operator,
|
||||||
'Custom'
|
)
|
||||||
|
? t(`filterBuilder.dueDateOption.${condition.operator}`)
|
||||||
|
: t('filterBuilder.customDueDate')
|
||||||
return `${typeLabel}: ${dueDateLabel}`
|
return `${typeLabel}: ${dueDateLabel}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,9 +296,9 @@ const ChoreToolbar = ({
|
|||||||
return member?.displayName || member?.username || String(value)
|
return member?.displayName || member?.username || String(value)
|
||||||
}
|
}
|
||||||
if (condition.type === 'status') {
|
if (condition.type === 'status') {
|
||||||
return (
|
return CHORE_STATUSES.some(s => s.value === value)
|
||||||
CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
? t(`filterBuilder.status.${value}`)
|
||||||
)
|
: String(value)
|
||||||
}
|
}
|
||||||
if (condition.type === 'priority') {
|
if (condition.type === 'priority') {
|
||||||
return Priorities.find(p => p.value === value)?.name || String(value)
|
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||||
|
|||||||
@@ -9,17 +9,20 @@ import {
|
|||||||
TaskAlt,
|
TaskAlt,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
|
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import Priorities from '../../../utils/Priorities'
|
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 = [
|
export const DUE_DATE_OPTIONS = [
|
||||||
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
|
{ value: 'isOverdue', color: 'danger' },
|
||||||
{ value: 'isDueToday', label: 'Today', color: 'warning' },
|
{ value: 'isDueToday', color: 'warning' },
|
||||||
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
|
{ value: 'isDueTomorrow', color: 'primary' },
|
||||||
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
|
{ value: 'isDueThisWeek', color: 'primary' },
|
||||||
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
|
{ value: 'isDueThisMonth', color: 'neutral' },
|
||||||
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
|
{ value: 'hasNoDueDate', color: 'neutral' },
|
||||||
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
|
{ value: 'hasDueDate', color: 'neutral' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const POINTS_OPERATORS = [
|
export const POINTS_OPERATORS = [
|
||||||
@@ -31,10 +34,10 @@ export const POINTS_OPERATORS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const CHORE_STATUSES = [
|
export const CHORE_STATUSES = [
|
||||||
{ value: 0, label: 'Active' },
|
{ value: 0 },
|
||||||
{ value: 1, label: 'Started' },
|
{ value: 1 },
|
||||||
{ value: 2, label: 'In Progress' },
|
{ value: 2 },
|
||||||
{ value: 3, label: 'Pending Approval' },
|
{ value: 3 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const defaultSelections = () => ({
|
export const defaultSelections = () => ({
|
||||||
@@ -119,35 +122,39 @@ const SectionHeader = ({ children, icon, label }) => (
|
|||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
const IncludeExcludeToggle = ({
|
const IncludeExcludeToggle = ({ labelKeys, onChange, value }) => {
|
||||||
labels = ['Include', 'Exclude'],
|
const { t } = useTranslation('chores')
|
||||||
onChange,
|
const [includeKey, excludeKey] = labelKeys ?? ['include', 'exclude']
|
||||||
value,
|
return (
|
||||||
}) => (
|
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
{[
|
||||||
{[
|
{ op: 'is', labelKey: includeKey },
|
||||||
{ op: 'is', label: labels[0] },
|
{ op: 'isNot', labelKey: excludeKey },
|
||||||
{ op: 'isNot', label: labels[1] },
|
].map(o => (
|
||||||
].map(o => (
|
<Chip
|
||||||
<Chip
|
key={o.op}
|
||||||
key={o.op}
|
size='sm'
|
||||||
size='sm'
|
variant={value === o.op ? 'solid' : 'soft'}
|
||||||
variant={value === o.op ? 'solid' : 'soft'}
|
color={
|
||||||
color={
|
value === o.op
|
||||||
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
|
? o.op === 'isNot'
|
||||||
}
|
? 'danger'
|
||||||
onClick={() => onChange(o.op)}
|
: 'primary'
|
||||||
sx={{
|
: 'neutral'
|
||||||
cursor: 'pointer',
|
}
|
||||||
userSelect: 'none',
|
onClick={() => onChange(o.op)}
|
||||||
transition: 'all 0.15s ease',
|
sx={{
|
||||||
}}
|
cursor: 'pointer',
|
||||||
>
|
userSelect: 'none',
|
||||||
{o.label}
|
transition: 'all 0.15s ease',
|
||||||
</Chip>
|
}}
|
||||||
))}
|
>
|
||||||
</Box>
|
{t(`filterBuilder.${o.labelKey}`)}
|
||||||
)
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
|
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
|
||||||
@@ -163,6 +170,7 @@ const FilterBuilderContent = ({
|
|||||||
projects = [],
|
projects = [],
|
||||||
selections,
|
selections,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const toggleValue = (type, value) =>
|
const toggleValue = (type, value) =>
|
||||||
onSelectionsChange(prev => {
|
onSelectionsChange(prev => {
|
||||||
const cur = prev[type].values || []
|
const cur = prev[type].values || []
|
||||||
@@ -272,7 +280,7 @@ const FilterBuilderContent = ({
|
|||||||
{/* Assignee */}
|
{/* Assignee */}
|
||||||
{members.length > 0 && (
|
{members.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader icon={<Person />} label='Assignee'>
|
<SectionHeader icon={<Person />} label={t('filterBuilder.assignee')}>
|
||||||
<IncludeExcludeToggle
|
<IncludeExcludeToggle
|
||||||
value={selections.assignee.operator}
|
value={selections.assignee.operator}
|
||||||
onChange={op => setOperator('assignee', op)}
|
onChange={op => setOperator('assignee', op)}
|
||||||
@@ -286,7 +294,7 @@ const FilterBuilderContent = ({
|
|||||||
{/* Created By */}
|
{/* Created By */}
|
||||||
{members.length > 0 && (
|
{members.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader icon={<Person />} label='Created By'>
|
<SectionHeader icon={<Person />} label={t('filterBuilder.createdBy')}>
|
||||||
<IncludeExcludeToggle
|
<IncludeExcludeToggle
|
||||||
value={selections.createdBy.operator}
|
value={selections.createdBy.operator}
|
||||||
onChange={op => setOperator('createdBy', op)}
|
onChange={op => setOperator('createdBy', op)}
|
||||||
@@ -298,17 +306,29 @@ const FilterBuilderContent = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<SectionHeader icon={<TaskAlt />} label='Status'>
|
<SectionHeader
|
||||||
|
icon={<TaskAlt />}
|
||||||
|
label={t('filterBuilder.statusHeading')}
|
||||||
|
>
|
||||||
<IncludeExcludeToggle
|
<IncludeExcludeToggle
|
||||||
value={selections.status.operator}
|
value={selections.status.operator}
|
||||||
onChange={op => setOperator('status', op)}
|
onChange={op => setOperator('status', op)}
|
||||||
/>
|
/>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
{chipRow('status', CHORE_STATUSES)}
|
{chipRow(
|
||||||
|
'status',
|
||||||
|
CHORE_STATUSES.map(st => ({
|
||||||
|
value: st.value,
|
||||||
|
label: t(`filterBuilder.status.${st.value}`),
|
||||||
|
})),
|
||||||
|
)}
|
||||||
<Divider sx={{ my: 2.5 }} />
|
<Divider sx={{ my: 2.5 }} />
|
||||||
|
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<SectionHeader icon={<PriorityHigh />} label='Priority'>
|
<SectionHeader
|
||||||
|
icon={<PriorityHigh />}
|
||||||
|
label={t('filterBuilder.priority')}
|
||||||
|
>
|
||||||
<IncludeExcludeToggle
|
<IncludeExcludeToggle
|
||||||
value={selections.priority.operator}
|
value={selections.priority.operator}
|
||||||
onChange={op => setOperator('priority', op)}
|
onChange={op => setOperator('priority', op)}
|
||||||
@@ -329,7 +349,10 @@ const FilterBuilderContent = ({
|
|||||||
<Divider sx={{ my: 2.5 }} />
|
<Divider sx={{ my: 2.5 }} />
|
||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date */}
|
||||||
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
|
<SectionHeader
|
||||||
|
icon={<CalendarMonth />}
|
||||||
|
label={t('filterBuilder.dueDate')}
|
||||||
|
/>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{DUE_DATE_OPTIONS.map(opt => {
|
{DUE_DATE_OPTIONS.map(opt => {
|
||||||
const isSelected = selections.dueDate.operator === opt.value
|
const isSelected = selections.dueDate.operator === opt.value
|
||||||
@@ -348,7 +371,7 @@ const FilterBuilderContent = ({
|
|||||||
transition: 'all 0.15s ease',
|
transition: 'all 0.15s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{t(`filterBuilder.dueDateOption.${opt.value}`)}
|
||||||
</Chip>
|
</Chip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -358,11 +381,11 @@ const FilterBuilderContent = ({
|
|||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{labels.length > 0 && (
|
{labels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader icon={<Label />} label='Labels'>
|
<SectionHeader icon={<Label />} label={t('filterBuilder.labels')}>
|
||||||
<IncludeExcludeToggle
|
<IncludeExcludeToggle
|
||||||
value={selections.label.operator}
|
value={selections.label.operator}
|
||||||
onChange={op => setOperator('label', op)}
|
onChange={op => setOperator('label', op)}
|
||||||
labels={['Has', "Doesn't Have"]}
|
labelKeys={['has', 'doesntHave']}
|
||||||
/>
|
/>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
@@ -410,14 +433,17 @@ const FilterBuilderContent = ({
|
|||||||
{/* Projects */}
|
{/* Projects */}
|
||||||
{projects.length > 0 && (
|
{projects.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader icon={<FolderOpen />} label='Projects'>
|
<SectionHeader
|
||||||
|
icon={<FolderOpen />}
|
||||||
|
label={t('filterBuilder.projects')}
|
||||||
|
>
|
||||||
<IncludeExcludeToggle
|
<IncludeExcludeToggle
|
||||||
value={selections.project.operator}
|
value={selections.project.operator}
|
||||||
onChange={op => setOperator('project', op)}
|
onChange={op => setOperator('project', op)}
|
||||||
/>
|
/>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
{chipRow('project', [
|
{chipRow('project', [
|
||||||
{ value: 'default', label: 'Default Project' },
|
{ value: 'default', label: t('filterBuilder.defaultProject') },
|
||||||
...projects
|
...projects
|
||||||
.filter(p => p.id !== 'default')
|
.filter(p => p.id !== 'default')
|
||||||
.map(p => ({ value: p.id, label: p.name })),
|
.map(p => ({ value: p.id, label: p.name })),
|
||||||
@@ -427,7 +453,7 @@ const FilterBuilderContent = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Points */}
|
{/* Points */}
|
||||||
<SectionHeader icon={<Stars />} label='Points' />
|
<SectionHeader icon={<Stars />} label={t('filterBuilder.points')} />
|
||||||
<Box
|
<Box
|
||||||
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||||||
>
|
>
|
||||||
@@ -479,7 +505,7 @@ const FilterBuilderContent = ({
|
|||||||
}
|
}
|
||||||
sx={{ cursor: 'pointer' }}
|
sx={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
Clear
|
{t('filterBuilder.clear')}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -78,19 +78,19 @@ const dateOnly = date => ({
|
|||||||
const DUE_DATE_PRESETS = [
|
const DUE_DATE_PRESETS = [
|
||||||
{
|
{
|
||||||
key: 'today',
|
key: 'today',
|
||||||
label: 'Today',
|
labelKey: 'presetToday',
|
||||||
resolve: () => dateOnly(moment()),
|
resolve: () => dateOnly(moment()),
|
||||||
hint: () => moment().format('ddd, MMM D'),
|
hint: () => moment().format('ddd, MMM D'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tomorrow',
|
key: 'tomorrow',
|
||||||
label: 'Tomorrow',
|
labelKey: 'presetTomorrow',
|
||||||
resolve: () => dateOnly(moment().add(1, 'day')),
|
resolve: () => dateOnly(moment().add(1, 'day')),
|
||||||
hint: () => moment().add(1, 'day').format('ddd, MMM D'),
|
hint: () => moment().add(1, 'day').format('ddd, MMM D'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'next-week',
|
key: 'next-week',
|
||||||
label: 'Next week',
|
labelKey: 'presetNextWeek',
|
||||||
resolve: () => dateOnly(moment().add(1, 'week').startOf('isoWeek')),
|
resolve: () => dateOnly(moment().add(1, 'week').startOf('isoWeek')),
|
||||||
hint: () => moment().add(1, 'week').startOf('isoWeek').format('ddd, MMM D'),
|
hint: () => moment().add(1, 'week').startOf('isoWeek').format('ddd, MMM D'),
|
||||||
},
|
},
|
||||||
@@ -189,18 +189,18 @@ const MultiSelectToolbar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dueDateValue = summary.dueDate?.isMixed
|
const dueDateValue = summary.dueDate?.isMixed
|
||||||
? 'Mixed'
|
? t('multiToolbar.mixed')
|
||||||
: summary.dueDate?.value
|
: summary.dueDate?.value
|
||||||
? moment(summary.dueDate.value).format('MMM D')
|
? moment(summary.dueDate.value).format('MMM D')
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const priorityValue = summary.priority?.isMixed
|
const priorityValue = summary.priority?.isMixed
|
||||||
? 'Mixed'
|
? t('multiToolbar.mixed')
|
||||||
: Priorities.find(p => p.value === summary.priority?.value)?.name.trim() ||
|
: Priorities.find(p => p.value === summary.priority?.value)?.name.trim() ||
|
||||||
null
|
null
|
||||||
|
|
||||||
const assigneeValue = summary.assignee?.isMixed
|
const assigneeValue = summary.assignee?.isMixed
|
||||||
? 'Mixed'
|
? t('multiToolbar.mixed')
|
||||||
: members.find(m => m.userId === summary.assignee?.value)?.displayName ||
|
: members.find(m => m.userId === summary.assignee?.value)?.displayName ||
|
||||||
null
|
null
|
||||||
|
|
||||||
@@ -389,11 +389,11 @@ const MultiSelectToolbar = ({
|
|||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
dueDateValue
|
dueDateValue
|
||||||
? `Due date on selected tasks: ${dueDateValue}`
|
? t('multiToolbar.dueTitleSet', { value: dueDateValue })
|
||||||
: 'Set due date on selected tasks'
|
: t('multiToolbar.dueTitleEmpty')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Due
|
{t('multiToolbar.due')}
|
||||||
</Button>
|
</Button>
|
||||||
<Menu
|
<Menu
|
||||||
size='md'
|
size='md'
|
||||||
@@ -414,7 +414,9 @@ const MultiSelectToolbar = ({
|
|||||||
<CalendarMonth sx={{ fontSize: 18 }} />
|
<CalendarMonth sx={{ fontSize: 18 }} />
|
||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm'>{preset.label}</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t(`multiToolbar.${preset.labelKey}`)}
|
||||||
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
sx={{ color: 'text.tertiary' }}
|
sx={{ color: 'text.tertiary' }}
|
||||||
@@ -435,7 +437,9 @@ const MultiSelectToolbar = ({
|
|||||||
<EditCalendar sx={{ fontSize: 18 }} />
|
<EditCalendar sx={{ fontSize: 18 }} />
|
||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm'>Pick date…</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('multiToolbar.pickDate')}
|
||||||
|
</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -449,7 +453,9 @@ const MultiSelectToolbar = ({
|
|||||||
<Remove sx={{ fontSize: 18 }} />
|
<Remove sx={{ fontSize: 18 }} />
|
||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm'>No due date</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('multiToolbar.noDueDate')}
|
||||||
|
</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -500,9 +506,9 @@ const MultiSelectToolbar = ({
|
|||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
}}
|
}}
|
||||||
title='Move selected tasks to a project'
|
title={t('multiToolbar.moveTitle')}
|
||||||
>
|
>
|
||||||
Move
|
{t('multiToolbar.move')}
|
||||||
</Button>
|
</Button>
|
||||||
<Menu
|
<Menu
|
||||||
size='md'
|
size='md'
|
||||||
@@ -514,14 +520,19 @@ const MultiSelectToolbar = ({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
closeProjectMenu()
|
closeProjectMenu()
|
||||||
onMoveToProject({ id: null, name: 'Default Project' })
|
onMoveToProject({
|
||||||
|
id: null,
|
||||||
|
name: t('multiToolbar.defaultProject'),
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemDecorator>
|
<ListItemDecorator>
|
||||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm'>Default Project</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('multiToolbar.defaultProject')}
|
||||||
|
</Typography>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
@@ -608,9 +619,9 @@ const MultiSelectToolbar = ({
|
|||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
}}
|
}}
|
||||||
title='Set priority, assignee, or labels'
|
title={t('multiToolbar.moreTitle')}
|
||||||
>
|
>
|
||||||
More
|
{t('multiToolbar.more')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -634,7 +645,7 @@ const MultiSelectToolbar = ({
|
|||||||
onClick={() => setMoreOpen(false)}
|
onClick={() => setMoreOpen(false)}
|
||||||
sx={{ minWidth: 140 }}
|
sx={{ minWidth: 140 }}
|
||||||
>
|
>
|
||||||
Done
|
{t('multiToolbar.done')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -646,7 +657,7 @@ const MultiSelectToolbar = ({
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={<Flag />}
|
icon={<Flag />}
|
||||||
label='Priority'
|
label={t('multiToolbar.priority')}
|
||||||
value={priorityValue}
|
value={priorityValue}
|
||||||
/>
|
/>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
@@ -677,7 +688,7 @@ const MultiSelectToolbar = ({
|
|||||||
onClick={runAndClose(() => onSetPriority(0))}
|
onClick={runAndClose(() => onSetPriority(0))}
|
||||||
sx={selectableChipSx}
|
sx={selectableChipSx}
|
||||||
>
|
>
|
||||||
None
|
{t('multiToolbar.noPriority')}
|
||||||
</Chip>
|
</Chip>
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
</>
|
</>
|
||||||
@@ -688,7 +699,7 @@ const MultiSelectToolbar = ({
|
|||||||
<Divider sx={{ my: 2.5 }} />
|
<Divider sx={{ my: 2.5 }} />
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={<Person />}
|
icon={<Person />}
|
||||||
label='Assignee'
|
label={t('multiToolbar.assignee')}
|
||||||
value={assigneeValue}
|
value={assigneeValue}
|
||||||
/>
|
/>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
@@ -731,8 +742,8 @@ const MultiSelectToolbar = ({
|
|||||||
only some of the selection has it — tapping completes the set. */}
|
only some of the selection has it — tapping completes the set. */}
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={<LabelIcon />}
|
icon={<LabelIcon />}
|
||||||
label='Labels'
|
label={t('multiToolbar.labels')}
|
||||||
value='Tap to add · tap again to remove'
|
value={t('multiToolbar.labelsHint')}
|
||||||
/>
|
/>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
{labels.map(label => {
|
{labels.map(label => {
|
||||||
|
|||||||
@@ -180,11 +180,11 @@ const ChoresOverview = () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{/* first column has minium size because its icon */}
|
{/* first column has minium size because its icon */}
|
||||||
<th style={{ width: 100 }}>Due</th>
|
<th style={{ width: 100 }}>{t('overview.colDue')}</th>
|
||||||
<th>Chore</th>
|
<th>{t('overview.colChore')}</th>
|
||||||
<th>Assignee</th>
|
<th>{t('overview.colAssignee')}</th>
|
||||||
<th>Due</th>
|
<th>{t('overview.colDue')}</th>
|
||||||
<th>Action</th>
|
<th>{t('overview.colAction')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
|
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||||
@@ -23,6 +24,7 @@ const enter = (delay = 0) => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const JoinCircleView = () => {
|
const JoinCircleView = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
|
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
|
||||||
// Read the token rather than useAuth(): the provider's copy only updates
|
// Read the token rather than useAuth(): the provider's copy only updates
|
||||||
// through its own login(), so signup and the OAuth callback — which save
|
// through its own login(), so signup and the OAuth callback — which save
|
||||||
@@ -49,20 +51,18 @@ const JoinCircleView = () => {
|
|||||||
clearPendingInvite()
|
clearPendingInvite()
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showAcknowledgment(
|
showAcknowledgment(
|
||||||
'Your request has been sent. A circle admin will need to approve ' +
|
t('joinCircle.requestSentBody'),
|
||||||
"it before you can access the circle and its chores. We'll " +
|
t('joinCircle.requestSentTitle'),
|
||||||
"notify you when it's approved.",
|
|
||||||
'Request sent',
|
|
||||||
() => navigate('/chores'),
|
() => navigate('/chores'),
|
||||||
'Got it',
|
t('joinCircle.gotIt'),
|
||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
setIsJoining(false)
|
setIsJoining(false)
|
||||||
if (resp.status === 409) {
|
if (resp.status === 409) {
|
||||||
showError('You are already a member of this circle')
|
showError(t('joinCircle.alreadyMember'))
|
||||||
} else {
|
} else {
|
||||||
showError('Failed to join circle')
|
showError(t('joinCircle.joinFailed'))
|
||||||
}
|
}
|
||||||
navigate('/chores')
|
navigate('/chores')
|
||||||
}
|
}
|
||||||
@@ -70,9 +70,9 @@ const JoinCircleView = () => {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
setIsJoining(false)
|
setIsJoining(false)
|
||||||
clearPendingInvite()
|
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
|
// 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
|
// 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 subtitle = null
|
||||||
let body = null
|
let body = null
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
title = 'Invite link is incomplete'
|
title = t('joinCircle.incompleteTitle')
|
||||||
subtitle =
|
subtitle = t('joinCircle.incompleteSubtitle')
|
||||||
'This invite link is missing a code. Ask the person who invited you to send a new link.'
|
|
||||||
body = (
|
body = (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -117,14 +116,13 @@ const JoinCircleView = () => {
|
|||||||
sx={authButtonSx}
|
sx={authButtonSx}
|
||||||
onClick={() => navigate('/chores')}
|
onClick={() => navigate('/chores')}
|
||||||
>
|
>
|
||||||
Go to Donetick
|
{t('joinCircle.goToDonetick')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
// A token that no longer resolves to a profile is as good as signed out —
|
// A token that no longer resolves to a profile is as good as signed out —
|
||||||
// better to offer sign-in than to spin forever.
|
// better to offer sign-in than to spin forever.
|
||||||
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
|
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
|
||||||
subtitle =
|
subtitle = t('joinCircle.signedOutSubtitle')
|
||||||
"Sign in or create a Donetick account to continue. We'll send your join request once you're signed in."
|
|
||||||
body = (
|
body = (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
{inviteCodeField}
|
{inviteCodeField}
|
||||||
@@ -134,7 +132,7 @@ const JoinCircleView = () => {
|
|||||||
sx={authButtonSx}
|
sx={authButtonSx}
|
||||||
onClick={() => goToAuth('/login')}
|
onClick={() => goToAuth('/login')}
|
||||||
>
|
>
|
||||||
Sign in
|
{t('joinCircle.signIn')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -144,32 +142,32 @@ const JoinCircleView = () => {
|
|||||||
sx={authButtonSx}
|
sx={authButtonSx}
|
||||||
onClick={() => goToAuth('/signup')}
|
onClick={() => goToAuth('/signup')}
|
||||||
>
|
>
|
||||||
Create an account
|
{t('joinCircle.createAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else if (isProfileLoading || isJoining) {
|
} else if (isProfileLoading || isJoining) {
|
||||||
title = 'Sending your request'
|
title = t('joinCircle.sendingTitle')
|
||||||
subtitle = 'Sending your request…'
|
subtitle = t('joinCircle.sendingSubtitle')
|
||||||
body = (
|
body = (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
subtitle =
|
subtitle = t('joinCircle.greetingSubtitle', {
|
||||||
`Hi ${userProfile?.displayName || userProfile?.username}. ` +
|
name: userProfile?.displayName || userProfile?.username,
|
||||||
"Send a request to share this circle's chores with its members."
|
})
|
||||||
body = (
|
body = (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||||
>
|
>
|
||||||
A circle admin will review your request before you get access.
|
{t('joinCircle.adminReviewNote')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button fullWidth size='lg' sx={authButtonSx} onClick={submitJoin}>
|
<Button fullWidth size='lg' sx={authButtonSx} onClick={submitJoin}>
|
||||||
Send join request
|
{t('joinCircle.sendRequest')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -182,7 +180,7 @@ const JoinCircleView = () => {
|
|||||||
navigate('/chores')
|
navigate('/chores')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||||
import {
|
import {
|
||||||
@@ -106,6 +107,7 @@ const IconHalo = ({ color = 'primary', icon }) => (
|
|||||||
*/
|
*/
|
||||||
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
const { t } = useTranslation('common')
|
||||||
const isBugReport = !error
|
const isBugReport = !error
|
||||||
|
|
||||||
const [report, setReport] = useState(null)
|
const [report, setReport] = useState(null)
|
||||||
@@ -175,21 +177,25 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
level='h4'
|
level='h4'
|
||||||
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
|
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
|
||||||
>
|
>
|
||||||
{isBugReport ? 'Report an issue' : 'Report this problem'}
|
{isBugReport
|
||||||
|
? t('errorReport.titleBug')
|
||||||
|
: t('errorReport.titleCrash')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
||||||
>
|
>
|
||||||
{isBugReport
|
{isBugReport
|
||||||
? 'Tell us what went wrong and we’ll attach the technical details for you.'
|
? t('errorReport.subtitleBug')
|
||||||
: 'A sentence about what you were doing turns this into something we can actually fix.'}
|
: t('errorReport.subtitleCrash')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FormControl sx={{ ...enter(100) }}>
|
<FormControl sx={{ ...enter(100) }}>
|
||||||
<FormLabel sx={{ fontWeight: 600 }}>
|
<FormLabel sx={{ fontWeight: 600 }}>
|
||||||
{isBugReport ? 'What went wrong?' : 'What were you doing?'}
|
{isBugReport
|
||||||
|
? t('errorReport.labelBug')
|
||||||
|
: t('errorReport.labelCrash')}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Textarea
|
<Textarea
|
||||||
minRows={3}
|
minRows={3}
|
||||||
@@ -199,24 +205,24 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
isBugReport
|
isBugReport
|
||||||
? 'e.g. Completing a chore from the list doesn’t update the due date'
|
? t('errorReport.placeholderBug')
|
||||||
: 'e.g. I tapped a chore in My Chores and the screen went blank'
|
: t('errorReport.placeholderCrash')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl sx={{ ...enter(140) }}>
|
<FormControl sx={{ ...enter(140) }}>
|
||||||
<FormLabel sx={{ fontWeight: 600 }}>
|
<FormLabel sx={{ fontWeight: 600 }}>
|
||||||
Email{' '}
|
{t('errorReport.email')}{' '}
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||||
(optional — only if you want a reply)
|
{t('errorReport.emailOptional')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
type='email'
|
type='email'
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
placeholder='you@example.com'
|
placeholder={t('errorReport.emailPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -236,7 +242,9 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
}
|
}
|
||||||
sx={{ px: 0 }}
|
sx={{ px: 0 }}
|
||||||
>
|
>
|
||||||
{showDetails ? 'Hide' : 'Show'} what gets sent
|
{showDetails
|
||||||
|
? t('errorReport.hideWhatGetsSent')
|
||||||
|
: t('errorReport.showWhatGetsSent')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showDetails && (
|
{showDetails && (
|
||||||
@@ -258,7 +266,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
color={copied ? 'success' : 'neutral'}
|
color={copied ? 'success' : 'neutral'}
|
||||||
onClick={copyDetails}
|
onClick={copyDetails}
|
||||||
sx={{ position: 'absolute', top: 6, right: 6, minWidth: 0 }}
|
sx={{ position: 'absolute', top: 6, right: 6, minWidth: 0 }}
|
||||||
aria-label='Copy diagnostics'
|
aria-label={t('errorReport.copyDiagnostics')}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<CheckRounded fontSize='small' />
|
<CheckRounded fontSize='small' />
|
||||||
@@ -276,7 +284,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{reportText || 'Collecting diagnostics…'}
|
{reportText || t('errorReport.collecting')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -284,7 +292,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
level='body-xs'
|
level='body-xs'
|
||||||
sx={{ color: 'text.tertiary', mt: 1, display: 'block' }}
|
sx={{ color: 'text.tertiary', mt: 1, display: 'block' }}
|
||||||
>
|
>
|
||||||
No chore names, notes or attachments are included.
|
{t('errorReport.privacyNote')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -298,7 +306,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
disabled={!report || (isBugReport && !description.trim())}
|
disabled={!report || (isBugReport && !description.trim())}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Send report
|
{t('errorReport.send')}
|
||||||
</Button>
|
</Button>
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
<Link
|
<Link
|
||||||
@@ -309,7 +317,9 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
underline='hover'
|
underline='hover'
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{isBugReport ? 'Cancel' : 'Not now'}
|
{isBugReport
|
||||||
|
? t('errorReport.cancel')
|
||||||
|
: t('errorReport.notNow')}
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -323,13 +333,13 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ ...enter(50) }}>
|
<Box sx={{ ...enter(50) }}>
|
||||||
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
||||||
Report sent
|
{t('errorReport.sentTitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ color: 'text.secondary', mt: 0.5 }}
|
sx={{ color: 'text.secondary', mt: 0.5 }}
|
||||||
>
|
>
|
||||||
Thanks! this goes straight to the people who can fix it.
|
{t('errorReport.sentBody')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -350,7 +360,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
>
|
>
|
||||||
<Box sx={{ textAlign: 'left' }}>
|
<Box sx={{ textAlign: 'left' }}>
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||||
Reference
|
{t('errorReport.reference')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
@@ -371,14 +381,14 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}}
|
}}
|
||||||
sx={{ minWidth: 0 }}
|
sx={{ minWidth: 0 }}
|
||||||
aria-label='Copy reference'
|
aria-label={t('errorReport.copyReference')}
|
||||||
>
|
>
|
||||||
{copied ? <CheckRounded /> : <ContentCopyRounded />}
|
{copied ? <CheckRounded /> : <ContentCopyRounded />}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button size='lg' fullWidth onClick={onClose} sx={{ ...enter(150) }}>
|
<Button size='lg' fullWidth onClick={onClose} sx={{ ...enter(150) }}>
|
||||||
Done
|
{t('errorReport.done')}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -390,15 +400,13 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ ...enter(50) }}>
|
<Box sx={{ ...enter(50) }}>
|
||||||
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
||||||
Finish on GitHub
|
{t('errorReport.githubTitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
||||||
>
|
>
|
||||||
Nothing has been sent. We've filled in an issue with your
|
{t('errorReport.githubBody')}
|
||||||
notes and the diagnostics — review it and post when you're
|
|
||||||
happy with it.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack spacing={1} sx={{ ...enter(100) }}>
|
<Stack spacing={1} sx={{ ...enter(100) }}>
|
||||||
@@ -411,7 +419,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Open pre-filled issue
|
{t('errorReport.openIssue')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='plain'
|
variant='plain'
|
||||||
@@ -421,7 +429,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
}
|
}
|
||||||
onClick={copyDetails}
|
onClick={copyDetails}
|
||||||
>
|
>
|
||||||
{copied ? 'Copied' : 'Copy details instead'}
|
{copied ? t('errorReport.copied') : t('errorReport.copyInstead')}
|
||||||
</Button>
|
</Button>
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
<Link
|
<Link
|
||||||
@@ -432,7 +440,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
|||||||
underline='hover'
|
underline='hover'
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Close
|
{t('errorReport.close')}
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ const AdvancedFilterBuilder = ({
|
|||||||
{FILTER_COLORS.map(c => (
|
{FILTER_COLORS.map(c => (
|
||||||
<Box
|
<Box
|
||||||
key={c.value}
|
key={c.value}
|
||||||
title={t(`common:colors.${c.key}`)}
|
title={t(`common:colors.${c.name}`)}
|
||||||
onClick={() => setFilterColor(c.value)}
|
onClick={() => setFilterColor(c.value)}
|
||||||
sx={{
|
sx={{
|
||||||
width: 26,
|
width: 26,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
handleClose()
|
handleClose()
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
setError(errorData.message || 'Failed to create backup')
|
setError(errorData.message || t('backup.createFailed'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('backup.createFailed'))
|
setError(t('backup.createFailed'))
|
||||||
@@ -156,7 +156,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
handleClose()
|
handleClose()
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
setError(errorData.message || 'Failed to restore backup')
|
setError(errorData.message || t('backup.restoreFailed'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('backup.restoreFailed'))
|
setError(t('backup.restoreFailed'))
|
||||||
@@ -288,7 +288,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
title='Backup & Restore'
|
title={t('backup.title')}
|
||||||
closeOnBackdrop={!loading}
|
closeOnBackdrop={!loading}
|
||||||
closeOnEscape={!loading}
|
closeOnEscape={!loading}
|
||||||
footer={
|
footer={
|
||||||
@@ -299,7 +299,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
disabled: loading,
|
disabled: loading,
|
||||||
}}
|
}}
|
||||||
primary={{
|
primary={{
|
||||||
label: activeTab === 0 ? 'Create Backup' : 'Restore Backup',
|
label:
|
||||||
|
activeTab === 0 ? t('backup.createTab') : t('backup.restoreTab'),
|
||||||
color: activeTab === 0 ? 'primary' : 'warning',
|
color: activeTab === 0 ? 'primary' : 'warning',
|
||||||
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
|
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
|
||||||
loading,
|
loading,
|
||||||
@@ -320,7 +321,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
>
|
>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
<Typography level='body-md' sx={{ ml: 2 }}>
|
<Typography level='body-md' sx={{ ml: 2 }}>
|
||||||
{activeTab === 0 ? 'Creating backup...' : 'Restoring backup...'}
|
{activeTab === 0 ? t('backup.creating') : t('backup.restoring')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
@@ -329,8 +330,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
onChange={(event, newValue) => setActiveTab(newValue)}
|
onChange={(event, newValue) => setActiveTab(newValue)}
|
||||||
>
|
>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>Create Backup</Tab>
|
<Tab>{t('backup.createTab')}</Tab>
|
||||||
<Tab>Restore Backup</Tab>
|
<Tab>{t('backup.restoreTab')}</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanel value={0}>{renderBackupTab()}</TabPanel>
|
<TabPanel value={0}>{renderBackupTab()}</TabPanel>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const IconPickerModal = ({
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(`icons.${iconData.key}`)}
|
{t(`icons.${iconData.value}`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
import { Box, Typography } from '@mui/joy'
|
import { Box, Typography } from '@mui/joy'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import ModalActions from '../../../components/common/ModalActions'
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
|
// The store steps embed bolded UI names mid-sentence, so they go through
|
||||||
|
// <Trans> rather than t() — translators keep the emphasis where their own
|
||||||
|
// word order needs it.
|
||||||
|
const Step = ({ i18nKey, ...props }) => (
|
||||||
|
<Typography level='body-sm' {...props}>
|
||||||
|
<Trans i18nKey={i18nKey} ns='settings' components={{ b: <strong /> }} />
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
|
||||||
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='lg'
|
||||||
title='Cancel Subscription'
|
title={t('nativeCancel.title')}
|
||||||
footer={
|
footer={
|
||||||
<ModalActions
|
<ModalActions
|
||||||
stackOnMobile
|
stackOnMobile
|
||||||
tertiary={{ label: 'Dismiss', onClick: onClose }}
|
tertiary={{ label: t('nativeCancel.dismiss'), onClick: onClose }}
|
||||||
secondary={{
|
secondary={{
|
||||||
label: "I'll cancel from my app store",
|
label: t('nativeCancel.cancelViaStore'),
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
}}
|
}}
|
||||||
primary={{
|
primary={{
|
||||||
label: 'Cancel desktop subscription',
|
label: t('nativeCancel.cancelDesktop'),
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
onClick: () => onClose('desktop'),
|
onClick: () => onClose('desktop'),
|
||||||
}}
|
}}
|
||||||
@@ -30,87 +41,46 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
|||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='body-md' mb={3}>
|
<Typography level='body-md' mb={3}>
|
||||||
To cancel your subscription, please follow the instructions for your
|
{t('nativeCancel.intro')}
|
||||||
platform (you should cancel through the same platform you used to
|
|
||||||
subscribe).
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='title-md' mb={2} color='primary'>
|
<Typography level='title-md' mb={2} color='primary'>
|
||||||
For iOS (iPhone/iPad):
|
{t('nativeCancel.iosHeading')}
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
1. Open the <strong>Settings</strong> app on your device
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
2. Tap your name at the top of the screen
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
3. Tap <strong>Subscriptions</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
4. Find and tap <strong>Donetick</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={2}>
|
|
||||||
5. Tap <strong>Cancel Subscription</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={2} color='warning'>
|
|
||||||
<strong>Note:</strong> If you subscribed through iOS and are using
|
|
||||||
the web/desktop version, you must cancel through iOS Settings as
|
|
||||||
described above.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Step i18nKey='nativeCancel.iosStep1' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.iosStep2' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.iosStep3' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.iosStep4' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.iosStep5' mb={2} />
|
||||||
|
<Step i18nKey='nativeCancel.iosNote' mb={2} color='warning' />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='title-md' mb={2} color='primary'>
|
<Typography level='title-md' mb={2} color='primary'>
|
||||||
For Android:
|
{t('nativeCancel.androidHeading')}
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
1. Open the <strong>Google Play Store</strong> app
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
2. Tap the profile icon in the top right
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
3. Tap <strong>Payments & subscriptions</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
4. Tap <strong>Subscriptions</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
5. Find and tap <strong>Donetick</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={2}>
|
|
||||||
6. Tap <strong>Cancel subscription</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={2} color='warning'>
|
|
||||||
<strong>Note:</strong> If you subscribed through Google Play and are
|
|
||||||
using the web/desktop version, you must cancel through Google Play
|
|
||||||
as described above.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Step i18nKey='nativeCancel.androidStep1' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.androidStep2' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.androidStep3' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.androidStep4' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.androidStep5' mb={1} />
|
||||||
|
<Step i18nKey='nativeCancel.androidStep6' mb={2} />
|
||||||
|
<Step i18nKey='nativeCancel.androidNote' mb={2} color='warning' />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='title-md' mb={2} color='primary'>
|
<Typography level='title-md' mb={2} color='primary'>
|
||||||
For Web/Desktop Subscriptions:
|
{t('nativeCancel.webHeading')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm' mb={2}>
|
<Typography level='body-sm' mb={2}>
|
||||||
If you originally subscribed through our website or desktop app, you
|
{t('nativeCancel.webBody')}
|
||||||
can cancel your subscription by going to the Account Settings
|
|
||||||
section on our website. using a web browser
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={2} color='warning'>
|
|
||||||
<strong>Important:</strong> 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.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Step i18nKey='nativeCancel.webNote' mb={2} color='warning' />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography level='body-sm' mb={3} color='neutral'>
|
<Typography level='body-sm' mb={3} color='neutral'>
|
||||||
Your subscription will remain active until the end of your current
|
{t('nativeCancel.footer')}
|
||||||
billing period.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
|||||||
if (!passwordTouched || !confirmPasswordTouched) return
|
if (!passwordTouched || !confirmPasswordTouched) return
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setPasswordError('Passwords do not match')
|
setPasswordError(t('passwordChange.mismatch'))
|
||||||
} else if (password.length < 8) {
|
} else if (password.length < 8) {
|
||||||
setPasswordError(t('passwordChange.minLength'))
|
setPasswordError(t('passwordChange.minLength'))
|
||||||
} else if (password.length > 64) {
|
} else if (password.length > 64) {
|
||||||
@@ -42,7 +42,7 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
|||||||
onClose={() => handleAction(false)}
|
onClose={() => handleAction(false)}
|
||||||
size='sm'
|
size='sm'
|
||||||
title={t('accountSettings.changePassword')}
|
title={t('accountSettings.changePassword')}
|
||||||
description='Choose a password between 8 and 64 characters.'
|
description={t('passwordChange.description')}
|
||||||
footer={
|
footer={
|
||||||
<ModalActions
|
<ModalActions
|
||||||
secondary={{
|
secondary={{
|
||||||
@@ -58,13 +58,15 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormControl sx={{ mb: 2 }}>
|
<FormControl sx={{ mb: 2 }}>
|
||||||
<Typography level='body-sm'>New password</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('passwordChange.newPassword')}
|
||||||
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
name='password'
|
name='password'
|
||||||
type='password'
|
type='password'
|
||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
placeholder='Enter password'
|
placeholder={t('passwordChange.newPasswordPlaceholder')}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
setPasswordTouched(true)
|
setPasswordTouched(true)
|
||||||
@@ -74,13 +76,15 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl error={Boolean(passwordError)}>
|
<FormControl error={Boolean(passwordError)}>
|
||||||
<Typography level='body-sm'>Confirm password</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('passwordChange.confirmPassword')}
|
||||||
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
name='confirmPassword'
|
name='confirmPassword'
|
||||||
type='password'
|
type='password'
|
||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
placeholder='Repeat password'
|
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
setConfirmPasswordTouched(true)
|
setConfirmPasswordTouched(true)
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{/* Project Name */}
|
{/* Project Name */}
|
||||||
<FormControl required>
|
<FormControl required>
|
||||||
<FormLabel>Project Name</FormLabel>
|
<FormLabel>{t('modal.nameLabel')}</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
value={projectName}
|
value={projectName}
|
||||||
onChange={e => setProjectName(e.target.value)}
|
onChange={e => setProjectName(e.target.value)}
|
||||||
@@ -163,7 +163,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
|
|
||||||
{/* Project Description */}
|
{/* Project Description */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>{t('modal.descriptionLabel')}</FormLabel>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={projectDescription}
|
value={projectDescription}
|
||||||
onChange={e => setProjectDescription(e.target.value)}
|
onChange={e => setProjectDescription(e.target.value)}
|
||||||
@@ -176,7 +176,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
|
|
||||||
{/* Icon Selection */}
|
{/* Icon Selection */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Project Icon</FormLabel>
|
<FormLabel>{t('modal.iconLabel')}</FormLabel>
|
||||||
<Button
|
<Button
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
onClick={() => setIsIconPickerOpen(true)}
|
onClick={() => setIsIconPickerOpen(true)}
|
||||||
@@ -212,19 +212,20 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
}
|
}
|
||||||
sx={{ justifyContent: 'flex-start' }}
|
sx={{ justifyContent: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{PROJECT_ICONS.find(icon => icon.value === projectIcon)?.name ||
|
{PROJECT_ICONS.some(icon => icon.value === projectIcon)
|
||||||
'Select Icon'}
|
? t(`icons.${projectIcon}`)
|
||||||
|
: t('modal.selectIcon')}
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Color Selection */}
|
{/* Color Selection */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Project Color</FormLabel>
|
<FormLabel>{t('modal.colorLabel')}</FormLabel>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{PROJECT_COLORS.map(colorOption => (
|
{PROJECT_COLORS.map(colorOption => (
|
||||||
<Box
|
<Box
|
||||||
key={colorOption.value}
|
key={colorOption.value}
|
||||||
title={colorOption.name}
|
title={t(`common:colors.${colorOption.name}`)}
|
||||||
onClick={() => setProjectColor(colorOption.value)}
|
onClick={() => setProjectColor(colorOption.value)}
|
||||||
sx={{
|
sx={{
|
||||||
width: 26,
|
width: 26,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import ModalActions from '../../../components/common/ModalActions'
|
|||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
||||||
|
|
||||||
|
// Sent verbatim to the delete endpoint, so it stays English in every locale —
|
||||||
|
// the label around it is what gets translated.
|
||||||
|
const CONFIRMATION_WORD = 'DELETE'
|
||||||
|
|
||||||
function UserDeletionModal({ isOpen, onClose }) {
|
function UserDeletionModal({ isOpen, onClose }) {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
@@ -99,7 +103,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const executeUserDeletion = async () => {
|
const executeUserDeletion = async () => {
|
||||||
if (password.trim() === '' || confirmation !== 'DELETE') {
|
if (password.trim() === '' || confirmation !== CONFIRMATION_WORD) {
|
||||||
setError(t('deletion.passwordAndDelete'))
|
setError(t('deletion.passwordAndDelete'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,33 +157,27 @@ function UserDeletionModal({ isOpen, onClose }) {
|
|||||||
const renderWarningStep = () => (
|
const renderWarningStep = () => (
|
||||||
<>
|
<>
|
||||||
<Typography level='body-md' mb={2}>
|
<Typography level='body-md' mb={2}>
|
||||||
<strong>This action cannot be undone.</strong> Deleting your account
|
<strong>{t('deletion.warningLead')}</strong>{' '}
|
||||||
will permanently remove:
|
{t('deletion.warningIntro')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='body-sm' mb={1}>
|
{[
|
||||||
• Your user profile and authentication data
|
'removesProfile',
|
||||||
</Typography>
|
'removesChores',
|
||||||
<Typography level='body-sm' mb={1}>
|
'removesTokens',
|
||||||
• All your chores, chore history, and time tracking sessions
|
'removesStorage',
|
||||||
</Typography>
|
'removesPoints',
|
||||||
<Typography level='body-sm' mb={1}>
|
'removesCircles',
|
||||||
• API tokens, MFA sessions, and password reset tokens
|
].map(key => (
|
||||||
</Typography>
|
<Typography key={key} level='body-sm' mb={1}>
|
||||||
<Typography level='body-sm' mb={1}>
|
• {t(`deletion.${key}`)}
|
||||||
• Storage files and usage data
|
</Typography>
|
||||||
</Typography>
|
))}
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
• Points history and notifications
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' mb={1}>
|
|
||||||
• Circle memberships and relationships
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FormControl sx={{ mb: 2 }}>
|
<FormControl sx={{ mb: 2 }}>
|
||||||
<FormLabel>Enter your password to continue</FormLabel>
|
<FormLabel>{t('deletion.passwordToContinue')}</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
type='password'
|
type='password'
|
||||||
value={password}
|
value={password}
|
||||||
@@ -200,16 +198,15 @@ function UserDeletionModal({ isOpen, onClose }) {
|
|||||||
<>
|
<>
|
||||||
<Typography level='body-md' mb={3}>
|
<Typography level='body-md' mb={3}>
|
||||||
{t('deletion.transferIntro')}
|
{t('deletion.transferIntro')}
|
||||||
select new owners:
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{circlesRequiringTransfer.map(circle => (
|
{circlesRequiringTransfer.map(circle => (
|
||||||
<Card key={circle.id} sx={{ mb: 2, p: 2 }}>
|
<Card key={circle.id} sx={{ mb: 2, p: 2 }}>
|
||||||
<Typography level='title-sm' mb={1}>
|
<Typography level='title-sm' mb={1}>
|
||||||
Circle: {circle.name}
|
{t('deletion.circleLabel', { name: circle.name })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>New Owner</FormLabel>
|
<FormLabel>{t('deletion.newOwner')}</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('deletion.selectOwner')}
|
placeholder={t('deletion.selectOwner')}
|
||||||
value={
|
value={
|
||||||
@@ -243,12 +240,11 @@ function UserDeletionModal({ isOpen, onClose }) {
|
|||||||
{t('deletion.confirmPrompt')}
|
{t('deletion.confirmPrompt')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm' mb={2}>
|
<Typography level='body-sm' mb={2}>
|
||||||
on successful deletion, you will be logged out and redirected to the
|
{t('deletion.logoutNotice')}
|
||||||
login page.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<FormControl sx={{ mb: 2 }}>
|
<FormControl sx={{ mb: 2 }}>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>{t('deletion.passwordLabel')}</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
type='password'
|
type='password'
|
||||||
value={password}
|
value={password}
|
||||||
@@ -258,7 +254,9 @@ function UserDeletionModal({ isOpen, onClose }) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl sx={{ mb: 3 }}>
|
<FormControl sx={{ mb: 3 }}>
|
||||||
<FormLabel>Type "DELETE" to confirm</FormLabel>
|
<FormLabel>
|
||||||
|
{t('deletion.typeToConfirm', { word: CONFIRMATION_WORD })}
|
||||||
|
</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
value={confirmation}
|
value={confirmation}
|
||||||
onChange={e => setConfirmation(e.target.value)}
|
onChange={e => setConfirmation(e.target.value)}
|
||||||
@@ -323,7 +321,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
|||||||
? !password
|
? !password
|
||||||
: step === 2
|
: step === 2
|
||||||
? circlesRequiringTransfer.length !== transferOptions.length
|
? circlesRequiringTransfer.length !== transferOptions.length
|
||||||
: !password || confirmation !== 'DELETE',
|
: !password || confirmation !== CONFIRMATION_WORD,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
|
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||||
@@ -80,6 +81,7 @@ const IconHalo = ({ icon }) => (
|
|||||||
* a gate.
|
* a gate.
|
||||||
*/
|
*/
|
||||||
const CircleSetupView = () => {
|
const CircleSetupView = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||||
@@ -101,14 +103,20 @@ const CircleSetupView = () => {
|
|||||||
|
|
||||||
const copyCode = () => {
|
const copyCode = () => {
|
||||||
navigator.clipboard.writeText(inviteCode)
|
navigator.clipboard.writeText(inviteCode)
|
||||||
showNotification({ type: 'success', message: 'Code copied to clipboard' })
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: t('circleSetup.codeCopied'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = () => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`${window.location.protocol}//${window.location.host}/circle/join?code=${inviteCode}`,
|
`${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 () => {
|
const joinCircle = async () => {
|
||||||
@@ -120,8 +128,8 @@ const CircleSetupView = () => {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
clearPendingInvite()
|
clearPendingInvite()
|
||||||
showAcknowledgment(
|
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.",
|
t('circleSetup.requestSentBody'),
|
||||||
'Request Sent',
|
t('circleSetup.requestSentTitle'),
|
||||||
finish,
|
finish,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -129,8 +137,8 @@ const CircleSetupView = () => {
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message:
|
||||||
resp.status === 409
|
resp.status === 409
|
||||||
? 'You are already a member of this circle'
|
? t('circleSetup.alreadyMember')
|
||||||
: 'Failed to join circle',
|
: t('circleSetup.joinFailed'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -190,7 +198,9 @@ const CircleSetupView = () => {
|
|||||||
textWrap: 'balance',
|
textWrap: 'balance',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mode === 'invite' ? 'Bring your squad in' : 'Join a circle'}
|
{mode === 'invite'
|
||||||
|
? t('circleSetup.inviteTitle')
|
||||||
|
: t('circleSetup.joinTitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-md'
|
level='body-md'
|
||||||
@@ -201,8 +211,8 @@ const CircleSetupView = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mode === 'invite'
|
{mode === 'invite'
|
||||||
? 'Everyone who joins your Circle sees the same chores, takes their own turn, and stays in sync automatically.'
|
? t('circleSetup.inviteSubtitle')
|
||||||
: "Enter the code you were given and we'll send a request to join — the circle owner just needs to approve it."}
|
: t('circleSetup.joinSubtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -233,14 +243,14 @@ const CircleSetupView = () => {
|
|||||||
color: inviteCode ? 'text.primary' : 'text.tertiary',
|
color: inviteCode ? 'text.primary' : 'text.tertiary',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{inviteCode ?? 'Loading…'}
|
{inviteCode ?? t('circleSetup.loadingCode')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='primary'
|
color='primary'
|
||||||
disabled={!inviteCode}
|
disabled={!inviteCode}
|
||||||
onClick={copyCode}
|
onClick={copyCode}
|
||||||
aria-label='Copy circle code'
|
aria-label={t('circleSetup.copyCodeAria')}
|
||||||
>
|
>
|
||||||
<ContentCopyRounded />
|
<ContentCopyRounded />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -257,13 +267,13 @@ const CircleSetupView = () => {
|
|||||||
disabled={!inviteCode}
|
disabled={!inviteCode}
|
||||||
onClick={copyLink}
|
onClick={copyLink}
|
||||||
>
|
>
|
||||||
Copy invite link instead
|
{t('circleSetup.copyLinkInstead')}
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 1, ...enter(190) }}>
|
<Box sx={{ mt: 1, ...enter(190) }}>
|
||||||
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
||||||
Continue
|
{t('circleSetup.continue')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -277,7 +287,7 @@ const CircleSetupView = () => {
|
|||||||
startDecorator={<GroupAddRounded fontSize='small' />}
|
startDecorator={<GroupAddRounded fontSize='small' />}
|
||||||
onClick={() => setMode('join')}
|
onClick={() => setMode('join')}
|
||||||
>
|
>
|
||||||
Join an existing circle instead
|
{t('circleSetup.joinInstead')}
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -285,7 +295,7 @@ const CircleSetupView = () => {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Box sx={{ ...enter(120) }}>
|
<Box sx={{ ...enter(120) }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder='Enter code'
|
placeholder={t('circleSetup.codePlaceholder')}
|
||||||
value={joinCode}
|
value={joinCode}
|
||||||
onChange={e => setJoinCode(e.target.value)}
|
onChange={e => setJoinCode(e.target.value)}
|
||||||
size='lg'
|
size='lg'
|
||||||
@@ -302,7 +312,7 @@ const CircleSetupView = () => {
|
|||||||
onClick={joinCircle}
|
onClick={joinCircle}
|
||||||
sx={authButtonSx}
|
sx={authButtonSx}
|
||||||
>
|
>
|
||||||
Join Circle
|
{t('circleSetup.joinButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ textAlign: 'center', ...enter(200) }}>
|
<Box sx={{ textAlign: 'center', ...enter(200) }}>
|
||||||
@@ -318,7 +328,7 @@ const CircleSetupView = () => {
|
|||||||
setMode('invite')
|
setMode('invite')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back
|
{t('circleSetup.back')}
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CheckRounded } from '@mui/icons-material'
|
import { CheckRounded } from '@mui/icons-material'
|
||||||
import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy'
|
import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { track } from '../../analytics'
|
import { track } from '../../analytics'
|
||||||
@@ -23,16 +24,12 @@ const enter = (delay = 0) => ({
|
|||||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const SOURCES = [
|
// Keys, not labels: the selected value is recorded as the acquisition source,
|
||||||
'App Store / Google Play',
|
// so it has to stay stable across locales. Display copy lives in
|
||||||
'Reddit or a forum',
|
// `common:onboarding.heardAbout.sources.*`.
|
||||||
'Friend or family',
|
const SOURCES = ['appStore', 'reddit', 'friend', 'video', 'search', 'other']
|
||||||
'YouTube or TikTok',
|
|
||||||
'Search engine',
|
|
||||||
'Something else',
|
|
||||||
]
|
|
||||||
|
|
||||||
const OTHER = 'Something else'
|
const OTHER = 'other'
|
||||||
|
|
||||||
const Shell = ({ children }) => (
|
const Shell = ({ children }) => (
|
||||||
<Box
|
<Box
|
||||||
@@ -70,6 +67,7 @@ const Shell = ({ children }) => (
|
|||||||
* attribute, so it gets the privacy prompt below instead.
|
* attribute, so it gets the privacy prompt below instead.
|
||||||
*/
|
*/
|
||||||
const AcquisitionSurvey = ({ onDone }) => {
|
const AcquisitionSurvey = ({ onDone }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [detail, setDetail] = useState('')
|
const [detail, setDetail] = useState('')
|
||||||
|
|
||||||
@@ -102,13 +100,13 @@ const AcquisitionSurvey = ({ onDone }) => {
|
|||||||
textWrap: 'balance',
|
textWrap: 'balance',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Where'd you hear about Donetick?
|
{t('onboarding.heardAbout.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-md'
|
level='body-md'
|
||||||
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
|
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
|
||||||
>
|
>
|
||||||
Helps us know what's working. Totally optional.
|
{t('onboarding.heardAbout.subtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -143,7 +141,7 @@ const AcquisitionSurvey = ({ onDone }) => {
|
|||||||
transition: `border-color 200ms ${EASE}, background-color 200ms ${EASE}`,
|
transition: `border-color 200ms ${EASE}, background-color 200ms ${EASE}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{source}
|
{t(`onboarding.heardAbout.sources.${source}`)}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: '0 0 auto',
|
flex: '0 0 auto',
|
||||||
@@ -170,7 +168,7 @@ const AcquisitionSurvey = ({ onDone }) => {
|
|||||||
{selected === OTHER && (
|
{selected === OTHER && (
|
||||||
<Box sx={{ mt: 0.5, ...enter(0) }}>
|
<Box sx={{ mt: 0.5, ...enter(0) }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder='Tell us where'
|
placeholder={t('onboarding.heardAbout.detailPlaceholder')}
|
||||||
value={detail}
|
value={detail}
|
||||||
onChange={e => setDetail(e.target.value)}
|
onChange={e => setDetail(e.target.value)}
|
||||||
size='lg'
|
size='lg'
|
||||||
@@ -183,7 +181,7 @@ const AcquisitionSurvey = ({ onDone }) => {
|
|||||||
|
|
||||||
<Box sx={{ mt: 3, ...enter(120) }}>
|
<Box sx={{ mt: 3, ...enter(120) }}>
|
||||||
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
||||||
Continue
|
{t('onboarding.heardAbout.continue')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -196,27 +194,15 @@ const AcquisitionSurvey = ({ onDone }) => {
|
|||||||
underline='hover'
|
underline='hover'
|
||||||
onClick={finish}
|
onClick={finish}
|
||||||
>
|
>
|
||||||
Skip
|
{t('onboarding.heardAbout.skip')}
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIVACY_TOGGLES = [
|
// Copy lives in `common:onboarding.privacy.*`, keyed off `key`.
|
||||||
{
|
const PRIVACY_TOGGLES = ['crashReports', 'analytics']
|
||||||
key: 'crashReports',
|
|
||||||
label: 'Share crash reports',
|
|
||||||
description:
|
|
||||||
"When Donetick crashes, send us the error and what led to it (device, app version, the screen you were on) no task content. It's how we catch bugs that never show up in our own testing and ship fixes faster.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'analytics',
|
|
||||||
label: 'Share anonymous usage analytics',
|
|
||||||
description:
|
|
||||||
'Send anonymous usage events which features get used, which get ignored so we know what to build next instead of guessing. No task content, no personal data, nothing tied back to you.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The self-hosted counterpart to the attribution survey: since a self-hosted
|
* The self-hosted counterpart to the attribution survey: since a self-hosted
|
||||||
@@ -225,6 +211,7 @@ const PRIVACY_TOGGLES = [
|
|||||||
* an opt-in, not an opt-out.
|
* an opt-in, not an opt-out.
|
||||||
*/
|
*/
|
||||||
const PrivacyPreferences = ({ onDone }) => {
|
const PrivacyPreferences = ({ onDone }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [crashReports, setCrashReports] = useState(false)
|
const [crashReports, setCrashReports] = useState(false)
|
||||||
const [analytics, setAnalytics] = useState(false)
|
const [analytics, setAnalytics] = useState(false)
|
||||||
|
|
||||||
@@ -253,14 +240,13 @@ const PrivacyPreferences = ({ onDone }) => {
|
|||||||
textWrap: 'balance',
|
textWrap: 'balance',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Help us improve Donetick
|
{t('onboarding.privacy.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-md'
|
level='body-md'
|
||||||
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
|
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
|
||||||
>
|
>
|
||||||
Your server, your data. Both of these are off from this point unless
|
{t('onboarding.privacy.subtitle')}
|
||||||
you turn them on.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -272,7 +258,7 @@ const PrivacyPreferences = ({ onDone }) => {
|
|||||||
...enter(60),
|
...enter(60),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PRIVACY_TOGGLES.map(({ description, key, label }) => (
|
{PRIVACY_TOGGLES.map(key => (
|
||||||
<Box
|
<Box
|
||||||
key={key}
|
key={key}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -290,13 +276,13 @@ const PrivacyPreferences = ({ onDone }) => {
|
|||||||
>
|
>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||||
{label}
|
{t(`onboarding.privacy.${key}Label`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ mt: 0.25, color: 'text.secondary' }}
|
sx={{ mt: 0.25, color: 'text.secondary' }}
|
||||||
>
|
>
|
||||||
{description}
|
{t(`onboarding.privacy.${key}Description`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -310,7 +296,7 @@ const PrivacyPreferences = ({ onDone }) => {
|
|||||||
|
|
||||||
<Box sx={{ mt: 3, ...enter(120) }}>
|
<Box sx={{ mt: 3, ...enter(120) }}>
|
||||||
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
||||||
Continue
|
{t('onboarding.heardAbout.continue')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Button, Typography } from '@mui/joy'
|
import { Box, Button, Typography } from '@mui/joy'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { track } from '../../analytics'
|
import { track } from '../../analytics'
|
||||||
@@ -31,75 +32,57 @@ const SWIPE_THRESHOLD = 56
|
|||||||
* back. Every title is an outcome — nobody buys "reschedule from completion
|
* back. Every title is an outcome — nobody buys "reschedule from completion
|
||||||
* date", they buy never arguing about trash day again.
|
* date", they buy never arguing about trash day again.
|
||||||
*/
|
*/
|
||||||
|
// Copy lives in `common:onboarding.slides.*` — this array is module-level and
|
||||||
|
// cannot call t(), so each slide carries its key and is translated at render.
|
||||||
const SLIDES = [
|
const SLIDES = [
|
||||||
{
|
{ key: 'problem', Visual: ProblemVignette },
|
||||||
key: 'problem',
|
{ key: 'capture', Visual: CaptureVignette },
|
||||||
title: 'Stop forgetting the little things',
|
// { key: 'schedule', Visual: ScheduleVignette },
|
||||||
body: "Donetick remembers the last time it happened, and when it's due next. So nobody else has to.",
|
{ key: 'circle', Visual: TakesTurnsVignette },
|
||||||
Visual: ProblemVignette,
|
{ key: 'reminders', Visual: RemindersVignette, permission: true },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'capture',
|
|
||||||
title: 'Add it before you forget it',
|
|
||||||
body: "Type it, say it out loud, or snap a photo. Donetick fills in the due date, priority, labels, and who's on it.",
|
|
||||||
Visual: CaptureVignette,
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// key: 'schedule',
|
|
||||||
// title: 'It comes back exactly when it should',
|
|
||||||
// body: "Every week, every 3 months, or a month after you actually did it. Finishing late doesn't throw the rest of the year off.",
|
|
||||||
// Visual: ScheduleVignette,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
key: 'circle',
|
|
||||||
title: 'Everyone gets a turn, No more arguing',
|
|
||||||
body: 'Rotate in the order you pick, or at random. Donetick remembers who went last, so turns stay fair.',
|
|
||||||
Visual: TakesTurnsVignette,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reminders',
|
|
||||||
title: 'A nudge right when it matters & Widgets!',
|
|
||||||
body: "Get a reminder before something's due, or send a heads-up when someone forgets theirs. Today's list sits on your home screen too.",
|
|
||||||
Visual: RemindersVignette,
|
|
||||||
permission: true,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const Dots = ({ activeIndex, count, onSelect }) => (
|
const Dots = ({ activeIndex, count, onSelect }) => {
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
const { t } = useTranslation()
|
||||||
{Array.from({ length: count }, (_, index) => {
|
return (
|
||||||
const active = index === activeIndex
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
||||||
return (
|
{Array.from({ length: count }, (_, index) => {
|
||||||
<Box
|
const active = index === activeIndex
|
||||||
key={index}
|
return (
|
||||||
component='button'
|
<Box
|
||||||
type='button'
|
key={index}
|
||||||
aria-label={`Go to step ${index + 1}`}
|
component='button'
|
||||||
aria-current={active ? 'step' : undefined}
|
type='button'
|
||||||
onClick={() => onSelect(index)}
|
aria-label={t('onboarding.carousel.goToStep', {
|
||||||
sx={{
|
number: index + 1,
|
||||||
border: 'none',
|
})}
|
||||||
p: 0,
|
aria-current={active ? 'step' : undefined}
|
||||||
cursor: 'pointer',
|
onClick={() => onSelect(index)}
|
||||||
height: 6,
|
sx={{
|
||||||
width: active ? 22 : 6,
|
border: 'none',
|
||||||
borderRadius: '999px',
|
p: 0,
|
||||||
bgcolor: active ? 'primary.500' : 'neutral.softBg',
|
cursor: 'pointer',
|
||||||
transition: `width 280ms ${EASE}, background-color 280ms ease`,
|
height: 6,
|
||||||
'&:focus-visible': {
|
width: active ? 22 : 6,
|
||||||
outline: '2px solid',
|
borderRadius: '999px',
|
||||||
outlineColor: 'primary.500',
|
bgcolor: active ? 'primary.500' : 'neutral.softBg',
|
||||||
outlineOffset: '3px',
|
transition: `width 280ms ${EASE}, background-color 280ms ease`,
|
||||||
},
|
'&:focus-visible': {
|
||||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
outline: '2px solid',
|
||||||
}}
|
outlineColor: 'primary.500',
|
||||||
/>
|
outlineOffset: '3px',
|
||||||
)
|
},
|
||||||
})}
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
</Box>
|
}}
|
||||||
)
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const OnboardingView = () => {
|
const OnboardingView = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
const [drag, setDrag] = useState(0)
|
const [drag, setDrag] = useState(0)
|
||||||
@@ -227,7 +210,7 @@ const OnboardingView = () => {
|
|||||||
onClick={finish}
|
onClick={finish}
|
||||||
sx={{ fontWeight: 600, borderRadius: '999px' }}
|
sx={{ fontWeight: 600, borderRadius: '999px' }}
|
||||||
>
|
>
|
||||||
Skip
|
{t('onboarding.carousel.skip')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -235,7 +218,7 @@ const OnboardingView = () => {
|
|||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
role='group'
|
role='group'
|
||||||
aria-roledescription='carousel'
|
aria-roledescription='carousel'
|
||||||
aria-label='What you can do with Donetick'
|
aria-label={t('onboarding.carousel.ariaLabel')}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={endDrag}
|
onPointerUp={endDrag}
|
||||||
@@ -270,7 +253,10 @@ const OnboardingView = () => {
|
|||||||
key={slide.key}
|
key={slide.key}
|
||||||
role='group'
|
role='group'
|
||||||
aria-roledescription='slide'
|
aria-roledescription='slide'
|
||||||
aria-label={`${index + 1} of ${SLIDES.length}`}
|
aria-label={t('onboarding.carousel.slidePosition', {
|
||||||
|
index: index + 1,
|
||||||
|
total: SLIDES.length,
|
||||||
|
})}
|
||||||
aria-hidden={!active}
|
aria-hidden={!active}
|
||||||
sx={{
|
sx={{
|
||||||
flex: '0 0 100%',
|
flex: '0 0 100%',
|
||||||
@@ -320,7 +306,7 @@ const OnboardingView = () => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slide.title}
|
{t(`onboarding.slides.${slide.key}Title`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-md'
|
level='body-md'
|
||||||
@@ -333,7 +319,7 @@ const OnboardingView = () => {
|
|||||||
animation: `slideCopyIn 460ms ${EASE} 140ms both`,
|
animation: `slideCopyIn 460ms ${EASE} 140ms both`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slide.body}
|
{t(`onboarding.slides.${slide.key}Body`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -366,7 +352,7 @@ const OnboardingView = () => {
|
|||||||
startDecorator={<NotificationsActiveRounded />}
|
startDecorator={<NotificationsActiveRounded />}
|
||||||
sx={authButtonSx}
|
sx={authButtonSx}
|
||||||
>
|
>
|
||||||
Turn on reminders
|
{t('onboarding.carousel.turnOnReminders')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='plain'
|
variant='plain'
|
||||||
@@ -379,7 +365,7 @@ const OnboardingView = () => {
|
|||||||
onClick={finish}
|
onClick={finish}
|
||||||
sx={{ ...authButtonSx, mt: -1.5 }}
|
sx={{ ...authButtonSx, mt: -1.5 }}
|
||||||
>
|
>
|
||||||
Not now
|
{t('onboarding.carousel.notNow')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -390,7 +376,7 @@ const OnboardingView = () => {
|
|||||||
endDecorator={!isLast ? <ArrowForwardRounded /> : null}
|
endDecorator={!isLast ? <ArrowForwardRounded /> : null}
|
||||||
sx={authButtonSx}
|
sx={authButtonSx}
|
||||||
>
|
>
|
||||||
{isLast ? 'Get started' : 'Next'}
|
{isLast ? t('getStarted') : t('onboarding.carousel.next')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import { Purchases } from '@revenuecat/purchases-capacitor'
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||||
@@ -57,6 +58,7 @@ import StorageSettings from './StorageSettings'
|
|||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
@@ -83,8 +85,8 @@ const Settings = () => {
|
|||||||
message,
|
message,
|
||||||
title,
|
title,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
confirmText = 'Confirm',
|
confirmText = t('common.confirm'),
|
||||||
cancelText = 'Cancel',
|
cancelText = t('common.cancel'),
|
||||||
color = 'primary',
|
color = 'primary',
|
||||||
) => {
|
) => {
|
||||||
setConfirmModalConfig({
|
setConfirmModalConfig({
|
||||||
@@ -112,7 +114,7 @@ const Settings = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Failed to refresh member requests',
|
message: t('circleSettings.refreshFailed'),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
@@ -195,27 +197,29 @@ const Settings = () => {
|
|||||||
|
|
||||||
const getSubscriptionDetails = () => {
|
const getSubscriptionDetails = () => {
|
||||||
if (userProfile?.subscription === 'active') {
|
if (userProfile?.subscription === 'active') {
|
||||||
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${fmt.date(
|
return t('accountSettings.activeDescription', {
|
||||||
userProfile?.expiration,
|
date: fmt.date(userProfile?.expiration),
|
||||||
)}.`
|
})
|
||||||
} else if (userProfile?.subscription === 'cancelled') {
|
} else if (userProfile?.subscription === 'cancelled') {
|
||||||
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${fmt.date(
|
return t('accountSettings.cancelledDescription', {
|
||||||
userProfile?.expiration,
|
date: fmt.date(userProfile?.expiration),
|
||||||
)}.`
|
})
|
||||||
} else {
|
} else {
|
||||||
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
|
return t('accountSettings.freeDescription')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const getSubscriptionStatus = () => {
|
const getSubscriptionStatus = () => {
|
||||||
if (userProfile?.subscription === 'active') {
|
if (userProfile?.subscription === 'active') {
|
||||||
return `Plus`
|
return t('accountSettings.plus')
|
||||||
} else if (userProfile?.subscription === 'cancelled') {
|
} else if (userProfile?.subscription === 'cancelled') {
|
||||||
if (moment().isBefore(userProfile?.expiration)) {
|
if (moment().isBefore(userProfile?.expiration)) {
|
||||||
return `Plus(until ${fmt.date(userProfile?.expiration)})`
|
return t('accountSettings.plusUntil', {
|
||||||
|
date: fmt.date(userProfile?.expiration),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return `Free`
|
return t('accountSettings.free')
|
||||||
} else {
|
} else {
|
||||||
return `Free`
|
return t('accountSettings.free')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,18 +245,15 @@ const Settings = () => {
|
|||||||
<Container>
|
<Container>
|
||||||
<ProfileSettings />
|
<ProfileSettings />
|
||||||
<div className='grid gap-4 py-4' id='circle'>
|
<div className='grid gap-4 py-4' id='circle'>
|
||||||
<Typography level='h3'>Circle settings</Typography>
|
<Typography level='h3'>{t('circleSettings.title')}</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Your account is automatically connected to a Circle when you create or
|
{t('circleSettings.description')}
|
||||||
join one. Easily invite friends by sharing the unique Circle code or
|
|
||||||
link below. You'll receive a notification below when someone requests
|
|
||||||
to join your Circle.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='title-sm' mb={-1}>
|
<Typography level='title-sm' mb={-1}>
|
||||||
{userCircles[0]?.userRole === 'member'
|
{userCircles[0]?.userRole === 'member'
|
||||||
? `You part of ${userCircles[0]?.name} `
|
? t('circleSettings.memberOf', { name: userCircles[0]?.name })
|
||||||
: `You circle code is:`}
|
: t('circleSettings.yourCircleCode')}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={userCircles[0]?.invite_code}
|
value={userCircles[0]?.invite_code}
|
||||||
@@ -269,11 +270,11 @@ const Settings = () => {
|
|||||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Code copied to clipboard',
|
message: t('circleSettings.codeCopied'),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy Code
|
{t('circleSettings.copyCode')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
@@ -287,11 +288,11 @@ const Settings = () => {
|
|||||||
)
|
)
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Link copied to clipboard',
|
message: t('circleSettings.linkCopied'),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy Link
|
{t('circleSettings.copyLink')}
|
||||||
</Button>
|
</Button>
|
||||||
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
||||||
<Button
|
<Button
|
||||||
@@ -300,35 +301,37 @@ const Settings = () => {
|
|||||||
sx={{ ml: 1 }}
|
sx={{ ml: 1 }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showConfirmation(
|
showConfirmation(
|
||||||
'Are you sure you want to leave your circle?',
|
t('circleSettings.leaveConfirmMessage'),
|
||||||
'Leave Circle',
|
t('circleSettings.leaveConfirmTitle'),
|
||||||
() => {
|
() => {
|
||||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Left circle successfully',
|
message: t('circleSettings.leftCircle'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Failed to leave circle',
|
message: t('circleSettings.leaveFailed'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
'Leave',
|
t('circleSettings.leaveConfirmButton'),
|
||||||
'Cancel',
|
t('common.cancel'),
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Leave Circle
|
{t('circleSettings.leave')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography level='title-md'>Circle Members</Typography>
|
<Typography level='title-md'>
|
||||||
|
{t('circleSettings.circleMembers')}
|
||||||
|
</Typography>
|
||||||
{circleMembers.map(member => (
|
{circleMembers.map(member => (
|
||||||
<Card key={member.id} className='p-4'>
|
<Card key={member.id} className='p-4'>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
@@ -336,19 +339,27 @@ const Settings = () => {
|
|||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
{member.displayName.charAt(0).toUpperCase() +
|
{member.displayName.charAt(0).toUpperCase() +
|
||||||
member.displayName.slice(1)}
|
member.displayName.slice(1)}
|
||||||
{member.userId === userProfile.id ? '(You)' : ''}{' '}
|
{member.userId === userProfile.id
|
||||||
|
? t('circleSettings.you')
|
||||||
|
: ''}{' '}
|
||||||
<Chip>
|
<Chip>
|
||||||
{' '}
|
{' '}
|
||||||
{member.isActive ? member.role : 'Pending Approval'}
|
{member.isActive
|
||||||
|
? member.role
|
||||||
|
: t('circleSettings.pendingApproval')}
|
||||||
</Chip>
|
</Chip>
|
||||||
</Typography>
|
</Typography>
|
||||||
{member.isActive ? (
|
{member.isActive ? (
|
||||||
<Typography level='body-sm'>
|
<Typography level='body-sm'>
|
||||||
Joined on {fmt.date(member.createdAt)}
|
{t('circleSettings.joinedOn', {
|
||||||
|
date: fmt.date(member.createdAt),
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Typography level='body-sm' color='danger'>
|
<Typography level='body-sm' color='danger'>
|
||||||
Request to join {fmt.date(member.updatedAt)}
|
{t('circleSettings.requestedToJoin', {
|
||||||
|
date: fmt.date(member.updatedAt),
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -378,28 +389,14 @@ const Settings = () => {
|
|||||||
} else {
|
} else {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Failed to update role',
|
message: t('circleSettings.roleUpdateFailed'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{['member', 'manager', 'admin'].map((option, index) => (
|
||||||
{
|
<Option value={option} key={index}>
|
||||||
value: 'member',
|
|
||||||
description: 'Just a regular member of the circle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'manager',
|
|
||||||
description:
|
|
||||||
'Can impersonate users and perform actions on their behalf',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'admin',
|
|
||||||
description: 'Full access to the circle',
|
|
||||||
},
|
|
||||||
].map((option, index) => (
|
|
||||||
<Option value={option.value} key={index}>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -414,14 +411,13 @@ const Settings = () => {
|
|||||||
level='title-sm'
|
level='title-sm'
|
||||||
sx={{ mb: 0, mt: 0, lineHeight: 1.1 }}
|
sx={{ mb: 0, mt: 0, lineHeight: 1.1 }}
|
||||||
>
|
>
|
||||||
{option.value.charAt(0).toUpperCase() +
|
{t(`circleSettings.roles.${option}`)}
|
||||||
option.value.slice(1)}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ mt: 0, mb: 0, lineHeight: 1.1 }}
|
sx={{ mt: 0, mb: 0, lineHeight: 1.1 }}
|
||||||
>
|
>
|
||||||
{option.description}
|
{t(`circleSettings.roles.${option}Description`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Option>
|
</Option>
|
||||||
@@ -437,8 +433,10 @@ const Settings = () => {
|
|||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showConfirmation(
|
showConfirmation(
|
||||||
`Are you sure you want to remove ${member.displayName} from your circle?`,
|
t('circleSettings.removeMemberMessage', {
|
||||||
'Remove Member',
|
name: member.displayName,
|
||||||
|
}),
|
||||||
|
t('circleSettings.removeMemberTitle'),
|
||||||
() => {
|
() => {
|
||||||
DeleteCircleMember(
|
DeleteCircleMember(
|
||||||
member.circleId,
|
member.circleId,
|
||||||
@@ -447,7 +445,7 @@ const Settings = () => {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Removed member successfully',
|
message: t('circleSettings.memberRemoved'),
|
||||||
})
|
})
|
||||||
// Invalidate and refetch circle-related queries
|
// Invalidate and refetch circle-related queries
|
||||||
queryClient.invalidateQueries(['circleMembers'])
|
queryClient.invalidateQueries(['circleMembers'])
|
||||||
@@ -463,8 +461,8 @@ const Settings = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
'Remove',
|
t('common.remove'),
|
||||||
'Cancel',
|
t('common.cancel'),
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -485,11 +483,15 @@ const Settings = () => {
|
|||||||
mb: 1,
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='title-md'>Circle Member Requests</Typography>
|
<Typography level='title-md'>
|
||||||
|
{t('circleSettings.circleMemberRequests')}
|
||||||
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
{lastRefresh && (
|
{lastRefresh && (
|
||||||
<Typography level='body-sm' color='neutral'>
|
<Typography level='body-sm' color='neutral'>
|
||||||
Last updated: {fmt.dateTime(lastRefresh)}
|
{t('circleSettings.lastUpdated', {
|
||||||
|
time: fmt.dateTime(lastRefresh),
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -501,7 +503,9 @@ const Settings = () => {
|
|||||||
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
|
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
{isRefreshing
|
||||||
|
? t('circleSettings.refreshing')
|
||||||
|
: t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -509,21 +513,24 @@ const Settings = () => {
|
|||||||
{circleMemberRequests.map(request => (
|
{circleMemberRequests.map(request => (
|
||||||
<Card key={request.id} className='p-4'>
|
<Card key={request.id} className='p-4'>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
{request.displayName} wants to join your circle.
|
{t('circleSettings.wantsToJoin', { name: request.displayName })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='success'
|
color='success'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showConfirmation(
|
showConfirmation(
|
||||||
`Are you sure you want to accept ${request.displayName} (username: ${request.username}) to join your circle?`,
|
t('circleSettings.acceptRequestMessage', {
|
||||||
'Accept Member Request',
|
name: request.displayName,
|
||||||
|
username: request.username,
|
||||||
|
}),
|
||||||
|
t('circleSettings.acceptRequestTitle'),
|
||||||
() => {
|
() => {
|
||||||
AcceptCircleMemberRequest(request.id).then(resp => {
|
AcceptCircleMemberRequest(request.id).then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Accepted request successfully',
|
message: t('circleSettings.requestAccepted'),
|
||||||
})
|
})
|
||||||
// Invalidate and refetch circle-related queries
|
// Invalidate and refetch circle-related queries
|
||||||
queryClient.invalidateQueries(['circleMembers'])
|
queryClient.invalidateQueries(['circleMembers'])
|
||||||
@@ -540,26 +547,25 @@ const Settings = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
'Accept',
|
t('circleSettings.accept'),
|
||||||
'Cancel',
|
t('common.cancel'),
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Accept
|
{t('circleSettings.accept')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
<Divider> or </Divider>
|
<Divider> {t('circleSettings.or')} </Divider>
|
||||||
|
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
if want to join someone else's Circle? Ask them for their unique
|
{t('circleSettings.joinOtherDescription')}
|
||||||
Circle code or join link. Enter the code below to join their Circle.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography level='title-sm' mb={-1}>
|
<Typography level='title-sm' mb={-1}>
|
||||||
Enter Circle code:
|
{t('circleSettings.enterCircleCode')}
|
||||||
<Input
|
<Input
|
||||||
placeholder='Enter code'
|
placeholder={t('circleSettings.enterCodePlaceholder')}
|
||||||
value={circleInviteCode}
|
value={circleInviteCode}
|
||||||
onChange={e => setCircleInviteCode(e.target.value)}
|
onChange={e => setCircleInviteCode(e.target.value)}
|
||||||
size='lg'
|
size='lg'
|
||||||
@@ -575,20 +581,19 @@ const Settings = () => {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message:
|
message: t('circleSettings.joinedPending'),
|
||||||
'Joined circle successfully, wait for the circle owner to accept your request.',
|
|
||||||
})
|
})
|
||||||
setTimeout(() => navigate('/'), 3000)
|
setTimeout(() => navigate('/'), 3000)
|
||||||
} else {
|
} else {
|
||||||
if (resp.status === 409) {
|
if (resp.status === 409) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You are already a member of this circle',
|
message: t('circleSettings.alreadyMember'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Failed to join circle',
|
message: t('circleSettings.joinFailed'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setTimeout(() => navigate('/'), 3000)
|
setTimeout(() => navigate('/'), 3000)
|
||||||
@@ -596,24 +601,21 @@ const Settings = () => {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Join Circle
|
{t('circleSettings.joinCircle')}
|
||||||
</Button>
|
</Button>
|
||||||
</Typography>
|
</Typography>
|
||||||
{circleMembers.find(m => userProfile.id == m.userId)?.role ===
|
{circleMembers.find(m => userProfile.id == m.userId)?.role ===
|
||||||
'admin' && (
|
'admin' && (
|
||||||
<>
|
<>
|
||||||
<Typography level='title-lg' mt={2}>
|
<Typography level='title-lg' mt={2}>
|
||||||
Webhook
|
{t('advanced.webhookTitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-md' mt={-1}>
|
<Typography level='body-md' mt={-1}>
|
||||||
Webhooks allow you to send real-time notifications to other
|
{t('advanced.webhookDescription')}
|
||||||
services when events happen in your Circle. Configure a webhook
|
|
||||||
URL to receive real-time updates.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{!isPlusAccount(userProfile) && (
|
{!isPlusAccount(userProfile) && (
|
||||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||||
Webhook notifications are not available in the Basic plan.
|
{t('advanced.webhookPlusNotice')}
|
||||||
Upgrade to Plus to receive real-time updates via webhooks.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<FormControl sx={{ mt: 1 }}>
|
<FormControl sx={{ mt: 1 }}>
|
||||||
@@ -627,7 +629,7 @@ const Settings = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label='Enable Webhook'
|
label={t('advanced.webhookToggle')}
|
||||||
disabled={!isPlusAccount(userProfile)}
|
disabled={!isPlusAccount(userProfile)}
|
||||||
overlay
|
overlay
|
||||||
/>
|
/>
|
||||||
@@ -636,10 +638,10 @@ const Settings = () => {
|
|||||||
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
|
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Enable webhook notifications for tasks and things updates.{' '}
|
{t('advanced.webhookHelper')}{' '}
|
||||||
{userProfile && !isPlusAccount(userProfile) && (
|
{userProfile && !isPlusAccount(userProfile) && (
|
||||||
<Chip variant='soft' color='warning'>
|
<Chip variant='soft' color='warning'>
|
||||||
Plus Feature
|
{t('common.plusFeature')}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
@@ -647,7 +649,9 @@ const Settings = () => {
|
|||||||
|
|
||||||
{webhookURL !== null && (
|
{webhookURL !== null && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='title-sm'>Webhook URL</Typography>
|
<Typography level='title-sm'>
|
||||||
|
{t('advanced.webhookURL')}
|
||||||
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
value={webhookURL ? webhookURL : ''}
|
value={webhookURL ? webhookURL : ''}
|
||||||
onChange={e => setWebhookURL(e.target.value)}
|
onChange={e => setWebhookURL(e.target.value)}
|
||||||
@@ -670,19 +674,19 @@ const Settings = () => {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Webhook URL updated successfully',
|
message: t('advanced.webhookUpdated'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Failed to update webhook URL',
|
message: t('advanced.webhookUpdateFailed'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
disabled={!isPlusAccount(userProfile)}
|
disabled={!isPlusAccount(userProfile)}
|
||||||
>
|
>
|
||||||
Save
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -695,13 +699,13 @@ const Settings = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 py-4' id='account'>
|
<div className='grid gap-4 py-4' id='account'>
|
||||||
<Typography level='h3'>Account Settings</Typography>
|
<Typography level='h3'>{t('accountSettings.title')}</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Change your account settings, type or update your password
|
{t('accountSettings.description')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='title-md' mb={-1}>
|
<Typography level='title-md' mb={-1}>
|
||||||
Account Type : {getSubscriptionStatus()}
|
{t('accountSettings.accountType', { type: getSubscriptionStatus() })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm'>{getSubscriptionDetails()}</Typography>
|
<Typography level='body-sm'>{getSubscriptionDetails()}</Typography>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -733,8 +737,7 @@ const Settings = () => {
|
|||||||
queryClient.refetchQueries(['userProfile'])
|
queryClient.refetchQueries(['userProfile'])
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message:
|
message: t('accountSettings.purchase.success'),
|
||||||
'Purchase successful! Please restart the app to access Plus features.',
|
|
||||||
})
|
})
|
||||||
// invalidate user profile to get new subscription status:
|
// invalidate user profile to get new subscription status:
|
||||||
}
|
}
|
||||||
@@ -749,57 +752,49 @@ const Settings = () => {
|
|||||||
// Store problem
|
// Store problem
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message: t('accountSettings.purchase.storeConnection'),
|
||||||
'Store connection issue. Please check your network and try again.',
|
|
||||||
})
|
})
|
||||||
} else if (error.code === '3') {
|
} else if (error.code === '3') {
|
||||||
// Purchase not allowed
|
// Purchase not allowed
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message: t('accountSettings.purchase.notAllowed'),
|
||||||
'Purchases are not allowed on this device. Please check your device restrictions.',
|
|
||||||
})
|
})
|
||||||
} else if (error.code === '4') {
|
} else if (error.code === '4') {
|
||||||
// Product not available
|
// Product not available
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message: t('accountSettings.purchase.unavailable'),
|
||||||
'This subscription is not available. Please try again later.',
|
|
||||||
})
|
})
|
||||||
} else if (error.code === '5') {
|
} else if (error.code === '5') {
|
||||||
// Receipt already in use
|
// Receipt already in use
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message: t('accountSettings.purchase.alreadyProcessed'),
|
||||||
'This purchase has already been processed. If you believe this is an error, please contact support.',
|
|
||||||
})
|
})
|
||||||
} else if (error.code === '6') {
|
} else if (error.code === '6') {
|
||||||
// Missing receipt file
|
// Missing receipt file
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message: t('accountSettings.purchase.receiptMissing'),
|
||||||
'Purchase receipt missing. Please try purchasing again.',
|
|
||||||
})
|
})
|
||||||
} else if (error.code === '7') {
|
} else if (error.code === '7') {
|
||||||
// Network error
|
// Network error
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message: t('accountSettings.purchase.networkError'),
|
||||||
'Network error. Please check your connection and try again.',
|
|
||||||
})
|
})
|
||||||
} else if (error.code === '8') {
|
} else if (error.code === '8') {
|
||||||
// Invalid receipt
|
// Invalid receipt
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message:
|
message: t('accountSettings.purchase.invalidReceipt'),
|
||||||
'Invalid purchase receipt. Please contact support if this persists.',
|
|
||||||
})
|
})
|
||||||
} else if (error.code === '9') {
|
} else if (error.code === '9') {
|
||||||
// Payment pending
|
// Payment pending
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message:
|
message: t('accountSettings.purchase.pending'),
|
||||||
'Payment is pending approval. You will receive access once approved.',
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Generic error
|
// Generic error
|
||||||
@@ -808,7 +803,11 @@ const Settings = () => {
|
|||||||
console.error('Error occurred in purchase flow')
|
console.error('Error occurred in purchase flow')
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: `Purchase failed: ${error.message || 'Unknown error'}. Please try again or contact support.`,
|
message: t('accountSettings.purchase.failed', {
|
||||||
|
error:
|
||||||
|
error.message ||
|
||||||
|
t('accountSettings.purchase.unknownError'),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,7 +816,7 @@ const Settings = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Upgrade
|
{t('accountSettings.upgrade')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{userProfile?.subscription === 'active' && (
|
{userProfile?.subscription === 'active' && (
|
||||||
@@ -833,14 +832,14 @@ const Settings = () => {
|
|||||||
setNativeCancelModal(true)
|
setNativeCancelModal(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('accountSettings.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{import.meta.env.VITE_IS_SELF_HOSTED === 'true' && (
|
{import.meta.env.VITE_IS_SELF_HOSTED === 'true' && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='title-md' mb={1}>
|
<Typography level='title-md' mb={1}>
|
||||||
Password :
|
{t('accountSettings.password')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography mb={1} level='body-sm'></Typography>
|
<Typography mb={1} level='body-sm'></Typography>
|
||||||
<Button
|
<Button
|
||||||
@@ -849,7 +848,7 @@ const Settings = () => {
|
|||||||
setChangePasswordModal(true)
|
setChangePasswordModal(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Change Password
|
{t('accountSettings.changePassword')}
|
||||||
</Button>
|
</Button>
|
||||||
{changePasswordModal ? (
|
{changePasswordModal ? (
|
||||||
<PassowrdChangeModal
|
<PassowrdChangeModal
|
||||||
@@ -860,12 +859,12 @@ const Settings = () => {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Password changed successfully',
|
message: t('accountSettings.passwordChanged'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Password change failed',
|
message: t('accountSettings.passwordChangeFailed'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -879,18 +878,17 @@ const Settings = () => {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='title-md' mb={1} color='danger'>
|
<Typography level='title-md' mb={1} color='danger'>
|
||||||
Danger Zone
|
{t('accountSettings.dangerZone')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm' mb={2} color='neutral'>
|
<Typography level='body-sm' mb={2} color='neutral'>
|
||||||
Once you delete your account, there is no going back. Please be
|
{t('accountSettings.dangerZoneDescription')}
|
||||||
certain.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='danger'
|
color='danger'
|
||||||
onClick={() => setUserDeletionModal(true)}
|
onClick={() => setUserDeletionModal(true)}
|
||||||
>
|
>
|
||||||
Delete Account
|
{t('accountSettings.deleteAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
@@ -899,32 +897,26 @@ const Settings = () => {
|
|||||||
<APITokenSettings />
|
<APITokenSettings />
|
||||||
<StorageSettings />
|
<StorageSettings />
|
||||||
<div className='grid gap-4 py-4' id='sidepanel'>
|
<div className='grid gap-4 py-4' id='sidepanel'>
|
||||||
<Typography level='h3'>Sidepanel Customization</Typography>
|
<Typography level='h3'>{t('sidepanel.title')}</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Customize the layout and visibility of cards in the sidepanel. the
|
{t('sidepanel.detailedDescription')}
|
||||||
section only available on large screen devices such as tablets and
|
|
||||||
desktops..
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<SidepanelSettings />
|
<SidepanelSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 py-4' id='theme'>
|
<div className='grid gap-4 py-4' id='theme'>
|
||||||
<Typography level='h3'>Theme preferences</Typography>
|
<Typography level='h3'>{t('theme.title')}</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>{t('theme.description')}</Typography>
|
||||||
Choose how the site looks to you. Select a single theme, or sync with
|
|
||||||
your system and automatically switch between day and night themes.
|
|
||||||
</Typography>
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 py-4' id='localization'>
|
<div className='grid gap-4 py-4' id='localization'>
|
||||||
<Typography level='h3'>Localization</Typography>
|
<Typography level='h3'>{t('localization.title')}</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Customize language, date format, and regional preferences for your
|
{t('localization.descriptionLong')}
|
||||||
account. These settings will apply throughout the application.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<LocalizationSettings />
|
<LocalizationSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { track } from '../../analytics'
|
import { track } from '../../analytics'
|
||||||
@@ -52,6 +53,7 @@ import CreateThingModal from '../Modals/Inputs/CreateThingModal'
|
|||||||
import EditThingStateModal from '../Modals/Inputs/EditThingState'
|
import EditThingStateModal from '../Modals/Inputs/EditThingState'
|
||||||
|
|
||||||
const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
|
const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
|
||||||
|
const { t } = useTranslation('things')
|
||||||
const getThingIcon = type => {
|
const getThingIcon = type => {
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
return <Flip />
|
return <Flip />
|
||||||
@@ -190,7 +192,7 @@ const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
|
|||||||
px: 0.75,
|
px: 0.75,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{thing?.type}
|
{t(`types.${thing?.type}`, thing?.type)}
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -214,6 +216,7 @@ const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ThingsView = () => {
|
const ThingsView = () => {
|
||||||
|
const { t } = useTranslation('things')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [things, setThings] = useState([])
|
const [things, setThings] = useState([])
|
||||||
const [isShowCreateThingModal, setIsShowCreateThingModal] = useState(false)
|
const [isShowCreateThingModal, setIsShowCreateThingModal] = useState(false)
|
||||||
@@ -325,21 +328,21 @@ const ThingsView = () => {
|
|||||||
}
|
}
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Saved',
|
title: t('notify.savedTitle'),
|
||||||
message: 'Thing saved successfully',
|
message: t('notify.savedMessage'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
if (error?.queued) {
|
if (error?.queued) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to save thing',
|
title: t('notify.saveFailTitle'),
|
||||||
message: 'You are offline and the request has been queued',
|
message: t('notify.queuedMessage'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to save thing',
|
title: t('notify.saveFailTitle'),
|
||||||
message: 'An error occurred while saving the thing',
|
message: t('notify.saveFailMessage'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -351,10 +354,10 @@ const ThingsView = () => {
|
|||||||
const handleDeleteClick = thing => {
|
const handleDeleteClick = thing => {
|
||||||
setConfirmModelConfig({
|
setConfirmModelConfig({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
title: 'Delete Things',
|
title: t('notify.deleteTitle'),
|
||||||
confirmText: 'Delete',
|
confirmText: t('common:delete'),
|
||||||
cancelText: 'Cancel',
|
cancelText: t('common:cancel'),
|
||||||
message: 'Are you sure you want to delete this Thing?',
|
message: t('notify.deleteMessage'),
|
||||||
onClose: isConfirmed => {
|
onClose: isConfirmed => {
|
||||||
if (isConfirmed === true) {
|
if (isConfirmed === true) {
|
||||||
DeleteThing(thing.id)
|
DeleteThing(thing.id)
|
||||||
@@ -368,8 +371,8 @@ const ThingsView = () => {
|
|||||||
setThings(currentThings)
|
setThings(currentThings)
|
||||||
} else if (response.status === 405) {
|
} else if (response.status === 405) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to Delete Thing',
|
title: t('notify.deleteBlockedTitle'),
|
||||||
message: 'Unable to delete thing with associated tasks',
|
message: t('notify.deleteBlockedMessage'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// if method not allwo show snackbar:
|
// if method not allwo show snackbar:
|
||||||
@@ -377,13 +380,13 @@ const ThingsView = () => {
|
|||||||
.catch(error => {
|
.catch(error => {
|
||||||
if (error?.queued) {
|
if (error?.queued) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to delete thing',
|
title: t('notify.deleteFailTitle'),
|
||||||
message: 'You are offline and the request has been queued',
|
message: t('notify.queuedMessage'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to delete thing',
|
title: t('notify.deleteFailTitle'),
|
||||||
message: 'An error occurred while deleting the thing',
|
message: t('notify.deleteFailMessage'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -416,21 +419,21 @@ const ThingsView = () => {
|
|||||||
setThings(currentThings)
|
setThings(currentThings)
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Updated',
|
title: t('notify.updatedTitle'),
|
||||||
message: 'Thing state updated successfully',
|
message: t('notify.updatedMessage'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
if (error?.queued) {
|
if (error?.queued) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to update thing state',
|
title: t('notify.updateFailTitle'),
|
||||||
message: 'You are offline and the request has been queued',
|
message: t('notify.queuedMessage'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to update thing state',
|
title: t('notify.updateFailTitle'),
|
||||||
message: 'An error occurred while updating the thing state',
|
message: t('notify.updateFailMessage'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -448,15 +451,15 @@ const ThingsView = () => {
|
|||||||
setThings(currentThings)
|
setThings(currentThings)
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Updated',
|
title: t('notify.updatedTitle'),
|
||||||
message: 'Thing state updated successfully',
|
message: t('notify.updatedMessage'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
showError({
|
showError({
|
||||||
title: 'Unable to update thing state',
|
title: t('notify.updateFailTitle'),
|
||||||
message: 'An error occurred while updating the thing state',
|
message: t('notify.updateFailMessage'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -470,13 +473,10 @@ const ThingsView = () => {
|
|||||||
level='h3'
|
level='h3'
|
||||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||||
>
|
>
|
||||||
Things
|
{t('view.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
Things are custom fields that can be attached to tasks to capture
|
{t('view.description')}
|
||||||
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
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -486,7 +486,7 @@ const ThingsView = () => {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
slotProps={{ input: { ref: searchInputRef } }}
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
placeholder='Search things'
|
placeholder={t('view.searchPlaceholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{
|
sx={{
|
||||||
@@ -512,21 +512,21 @@ const ThingsView = () => {
|
|||||||
/>
|
/>
|
||||||
<SortAndFilterMenu
|
<SortAndFilterMenu
|
||||||
sortOptions={[
|
sortOptions={[
|
||||||
{ name: 'Name', value: 'name' },
|
{ name: t('view.sortName'), value: 'name' },
|
||||||
{ name: 'Type', value: 'type' },
|
{ name: t('view.sortType'), value: 'type' },
|
||||||
{ name: 'State', value: 'state' },
|
{ name: t('view.sortState'), value: 'state' },
|
||||||
{ name: 'Last updated', value: 'updated' },
|
{ name: t('view.sortUpdated'), value: 'updated' },
|
||||||
]}
|
]}
|
||||||
selectedSort={sortBy}
|
selectedSort={sortBy}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSortDirectionChange={setSortDirection}
|
onSortDirectionChange={setSortDirection}
|
||||||
filterTitle='Type'
|
filterTitle={t('view.filterTitle')}
|
||||||
filterOptions={[
|
filterOptions={[
|
||||||
{ name: 'All types', value: 'all' },
|
{ name: t('view.filterAll'), value: 'all' },
|
||||||
{ name: 'Text', value: 'text' },
|
{ name: t('types.text'), value: 'text' },
|
||||||
{ name: 'Number', value: 'number' },
|
{ name: t('types.number'), value: 'number' },
|
||||||
{ name: 'Boolean', value: 'boolean' },
|
{ name: t('types.boolean'), value: 'boolean' },
|
||||||
]}
|
]}
|
||||||
selectedFilter={typeFilter}
|
selectedFilter={typeFilter}
|
||||||
onFilterChange={value => {
|
onFilterChange={value => {
|
||||||
@@ -550,10 +550,10 @@ const ThingsView = () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<Widgets />}
|
icon={<Widgets />}
|
||||||
title='No things yet'
|
title={t('view.emptyTitle')}
|
||||||
description='A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.'
|
description={t('view.emptyDescription')}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: 'Create a thing',
|
label: t('view.emptyAction'),
|
||||||
startDecorator: <Add />,
|
startDecorator: <Add />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setCreateModalThing(null)
|
setCreateModalThing(null)
|
||||||
@@ -567,14 +567,14 @@ const ThingsView = () => {
|
|||||||
variant='no-results'
|
variant='no-results'
|
||||||
fullHeight
|
fullHeight
|
||||||
icon={<SearchOff />}
|
icon={<SearchOff />}
|
||||||
title='No things match'
|
title={t('view.noResultsTitle')}
|
||||||
description={
|
description={
|
||||||
searchTerm
|
searchTerm
|
||||||
? `No thing matches "${searchTerm}".`
|
? t('view.noResultsSearch', { term: searchTerm })
|
||||||
: 'No thing matches the current filter.'
|
: t('view.noResultsFilter')
|
||||||
}
|
}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: searchTerm ? 'Clear search' : 'Show all things',
|
label: searchTerm ? t('view.clearSearch') : t('view.showAll'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
handleSearchClose()
|
handleSearchClose()
|
||||||
setTypeFilter('all')
|
setTypeFilter('all')
|
||||||
@@ -628,7 +628,9 @@ const ThingsView = () => {
|
|||||||
<ToggleOff sx={{ fontSize: 20 }} />
|
<ToggleOff sx={{ fontSize: 20 }} />
|
||||||
)}
|
)}
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
{thing?.type === 'text' ? 'Edit' : 'Toggle'}
|
{thing?.type === 'text'
|
||||||
|
? t('common:edit')
|
||||||
|
: t('view.toggle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
@@ -647,7 +649,7 @@ const ThingsView = () => {
|
|||||||
>
|
>
|
||||||
<Edit sx={{ fontSize: 20 }} />
|
<Edit sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Edit
|
{t('common:edit')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
@@ -666,7 +668,7 @@ const ThingsView = () => {
|
|||||||
>
|
>
|
||||||
<Delete sx={{ fontSize: 20 }} />
|
<Delete sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Delete
|
{t('common:delete')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ const TimerDetails = () => {
|
|||||||
color: 'text.primary',
|
color: 'text.primary',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Active Work
|
{t('activeWork')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -572,7 +572,7 @@ const TimerDetails = () => {
|
|||||||
color: 'text.primary',
|
color: 'text.primary',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sessions
|
{t('sessions')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -661,12 +661,18 @@ const TimerDetails = () => {
|
|||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
|
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
|
||||||
>
|
>
|
||||||
Work vs Break Distribution
|
{t('distribution')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||||
{calculateCurrentActiveDuration() > 0
|
{calculateCurrentActiveDuration() > 0
|
||||||
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
|
? t('percentActive', {
|
||||||
: 'No active time yet'}
|
percent: Math.round(
|
||||||
|
(calculateCurrentActiveDuration() /
|
||||||
|
calculateTotalDuration()) *
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: t('noActiveTime')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
@@ -695,7 +701,7 @@ const TimerDetails = () => {
|
|||||||
level='body-sm'
|
level='body-sm'
|
||||||
sx={{ color: 'text.secondary', fontWeight: 'medium', mb: 2 }}
|
sx={{ color: 'text.secondary', fontWeight: 'medium', mb: 2 }}
|
||||||
>
|
>
|
||||||
Activity Timeline
|
{t('activityTimeline')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{timerData &&
|
{timerData &&
|
||||||
@@ -803,7 +809,7 @@ const TimerDetails = () => {
|
|||||||
level='body-xs'
|
level='body-xs'
|
||||||
sx={{ color: 'text.tertiary' }}
|
sx={{ color: 'text.tertiary' }}
|
||||||
>
|
>
|
||||||
Active Work
|
{t('activeWork')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
@@ -852,7 +858,7 @@ const TimerDetails = () => {
|
|||||||
level='body-xs'
|
level='body-xs'
|
||||||
sx={{ color: 'text.tertiary' }}
|
sx={{ color: 'text.tertiary' }}
|
||||||
>
|
>
|
||||||
Live Session
|
{t('liveSession')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -918,7 +924,7 @@ const TimerDetails = () => {
|
|||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='h4'>Session Breakdown</Typography>
|
<Typography level='h4'>{t('sessionBreakdown')}</Typography>
|
||||||
{!editingSessions[timerData.id] && (
|
{!editingSessions[timerData.id] && (
|
||||||
<Button
|
<Button
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
@@ -1112,7 +1118,7 @@ const TimerDetails = () => {
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
sx={{ fontSize: '0.7rem' }}
|
sx={{ fontSize: '0.7rem' }}
|
||||||
>
|
>
|
||||||
Live
|
{t('live')}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{/* User chip showing who started the session */}
|
{/* User chip showing who started the session */}
|
||||||
@@ -1211,8 +1217,8 @@ const TimerDetails = () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
size='sm'
|
size='sm'
|
||||||
icon={<AccessTime />}
|
icon={<AccessTime />}
|
||||||
title='No work sessions yet'
|
title={t('emptySessionsTitle')}
|
||||||
description='Start the timer on this task and each session lands here.'
|
description={t('emptySessionsDescription')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1237,7 +1243,7 @@ const TimerDetails = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
|
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
|
||||||
Sessions
|
{t('sessions')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -1340,9 +1346,7 @@ const TimerDetails = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>{t('leaveEmpty')}</FormHelperText>
|
||||||
Leave empty if session is ongoing
|
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import * as chrono from 'chrono-node'
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import ModalActions from '../../components/common/ModalActions'
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
@@ -186,6 +187,7 @@ const POINTS_SUGGESTIONS = {
|
|||||||
const PARSE_DEBOUNCE_MS = 150
|
const PARSE_DEBOUNCE_MS = 150
|
||||||
|
|
||||||
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||||
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
||||||
@@ -229,13 +231,13 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
value: 'userId',
|
value: 'userId',
|
||||||
display: 'displayName',
|
display: 'displayName',
|
||||||
options: [
|
options: [
|
||||||
{ userId: 'anyone', displayName: 'Anyone' },
|
{ userId: 'anyone', displayName: t('addTask.anyone') },
|
||||||
...(circleMembers?.res || []),
|
...(circleMembers?.res || []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'*': POINTS_SUGGESTIONS,
|
'*': POINTS_SUGGESTIONS,
|
||||||
}),
|
}),
|
||||||
[userLabels, circleMembers, handleCreateLabel],
|
[userLabels, circleMembers, handleCreateLabel, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [taskText, setTaskText] = useState('')
|
const [taskText, setTaskText] = useState('')
|
||||||
@@ -1156,7 +1158,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
title='Create new task'
|
title={t('addTask.title')}
|
||||||
footer={
|
footer={
|
||||||
<ModalActions>
|
<ModalActions>
|
||||||
{!showScan && !showVoice && projects?.length >= 1 && (
|
{!showScan && !showVoice && projects?.length >= 1 && (
|
||||||
@@ -1177,7 +1179,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedProject.name}
|
{selectedProject.id === 'default'
|
||||||
|
? t('addTask.defaultProject')
|
||||||
|
: selectedProject.name}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<Menu
|
<Menu
|
||||||
placement='top-start'
|
placement='top-start'
|
||||||
@@ -1199,7 +1203,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
sx={{ fontSize: 18, color: project.color }}
|
sx={{ fontSize: 18, color: project.color }}
|
||||||
/>
|
/>
|
||||||
</ListItemDecorator>
|
</ListItemDecorator>
|
||||||
{project.name}
|
{project.id === 'default'
|
||||||
|
? t('addTask.defaultProject')
|
||||||
|
: project.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1211,7 +1217,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
color='neutral'
|
color='neutral'
|
||||||
onClick={handleCloseModal}
|
onClick={handleCloseModal}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:cancel')}
|
||||||
{showKeyboardShortcuts && (
|
{showKeyboardShortcuts && (
|
||||||
<KeyboardShortcutHint
|
<KeyboardShortcutHint
|
||||||
shortcut='Esc'
|
shortcut='Esc'
|
||||||
@@ -1231,10 +1237,12 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
onClick={handleVoiceConfirm}
|
onClick={handleVoiceConfirm}
|
||||||
>
|
>
|
||||||
{creatingVoiceTasks
|
{creatingVoiceTasks
|
||||||
? 'Creating…'
|
? t('addTask.creating')
|
||||||
: voiceState.segments.length > 1
|
: voiceState.segments.length > 1
|
||||||
? `Create ${voiceState.segments.length} Tasks`
|
? t('addTask.createCount', {
|
||||||
: 'Use Task'}
|
count: voiceState.segments.length,
|
||||||
|
})
|
||||||
|
: t('addTask.useTask')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{showScan && scanState.primaryAction && (
|
{showScan && scanState.primaryAction && (
|
||||||
@@ -1249,7 +1257,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
{showScan && scanState.phase === 'processing' && (
|
{showScan && scanState.phase === 'processing' && (
|
||||||
<Button variant='solid' color='primary' loading disabled>
|
<Button variant='solid' color='primary' loading disabled>
|
||||||
Processing
|
{t('addTask.processing')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!showScan && !showVoice && (
|
{!showScan && !showVoice && (
|
||||||
@@ -1260,7 +1268,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
disabled={!taskTitle.trim() || isAttachingScan}
|
disabled={!taskTitle.trim() || isAttachingScan}
|
||||||
onClick={submitChore}
|
onClick={submitChore}
|
||||||
>
|
>
|
||||||
Create
|
{t('addTask.create')}
|
||||||
{showKeyboardShortcuts && (
|
{showKeyboardShortcuts && (
|
||||||
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||||
)}
|
)}
|
||||||
@@ -1349,7 +1357,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
onVoiceClick={
|
onVoiceClick={
|
||||||
voiceAvailable ? () => setShowVoice(true) : undefined
|
voiceAvailable ? () => setShowVoice(true) : undefined
|
||||||
}
|
}
|
||||||
placeholder='Type your task...'
|
placeholder={t('addTask.taskPlaceholder')}
|
||||||
onChange={text => {
|
onChange={text => {
|
||||||
setTaskText(text)
|
setTaskText(text)
|
||||||
if (!text) setTaskTitle('')
|
if (!text) setTaskTitle('')
|
||||||
@@ -1484,7 +1492,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Add sx={{ fontSize: 20 }} />
|
<Add sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-sm'>Description</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('addTask.descriptionChip')}
|
||||||
|
</Typography>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!hasSubTasks && (
|
{!hasSubTasks && (
|
||||||
@@ -1507,7 +1517,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Add sx={{ fontSize: 20 }} />
|
<Add sx={{ fontSize: 20 }} />
|
||||||
<Typography level='body-sm'>Subtasks</Typography>
|
<Typography level='body-sm'>
|
||||||
|
{t('addTask.subtasksChip')}
|
||||||
|
</Typography>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<AdvancedOptionsTrigger
|
<AdvancedOptionsTrigger
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const STRATEGY_OPTIONS = [
|
const STRATEGY_VALUES = [
|
||||||
{ value: 'keep_last_assigned', label: 'Keep same assignee' },
|
'keep_last_assigned',
|
||||||
{ value: 'random', label: 'Random' },
|
'random',
|
||||||
{ value: 'least_completed', label: 'Least completed' },
|
'least_completed',
|
||||||
{ value: 'round_robin', label: 'Round robin' },
|
'round_robin',
|
||||||
]
|
]
|
||||||
|
|
||||||
const FieldRow = ({ children, description, label, onLabelClick }) => (
|
const FieldRow = ({ children, description, label, onLabelClick }) => (
|
||||||
@@ -67,6 +68,7 @@ export const AdvancedOptionsTrigger = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
open,
|
open,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const showLabel = emptyDisplay === 'icon-text' || open || activeCount > 0
|
const showLabel = emptyDisplay === 'icon-text' || open || activeCount > 0
|
||||||
|
|
||||||
return (
|
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',
|
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
More
|
{t('advancedOptions.more')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ const AdvancedOptionsSection = ({
|
|||||||
points,
|
points,
|
||||||
requireApproval,
|
requireApproval,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const displayPoints = points <= 0 ? 0 : points
|
const displayPoints = points <= 0 ? 0 : points
|
||||||
|
|
||||||
const handleDecrement = () => {
|
const handleDecrement = () => {
|
||||||
@@ -199,8 +202,8 @@ const AdvancedOptionsSection = ({
|
|||||||
>
|
>
|
||||||
{/* Points */}
|
{/* Points */}
|
||||||
<FieldRow
|
<FieldRow
|
||||||
label='Points'
|
label={t('advancedOptions.points')}
|
||||||
description='Award points for completing this task'
|
description={t('advancedOptions.pointsDescription')}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -238,8 +241,8 @@ const AdvancedOptionsSection = ({
|
|||||||
|
|
||||||
{/* Require approval */}
|
{/* Require approval */}
|
||||||
<FieldRow
|
<FieldRow
|
||||||
label='Require approval'
|
label={t('advancedOptions.requireApproval')}
|
||||||
description='Task needs admin sign-off before it can be closed'
|
description={t('advancedOptions.requireApprovalDescription')}
|
||||||
onLabelClick={() => onRequireApprovalChange(!requireApproval)}
|
onLabelClick={() => onRequireApprovalChange(!requireApproval)}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -251,11 +254,11 @@ const AdvancedOptionsSection = ({
|
|||||||
|
|
||||||
{/* Privacy */}
|
{/* Privacy */}
|
||||||
<FieldRow
|
<FieldRow
|
||||||
label='Limited visibility'
|
label={t('advancedOptions.limitedVisibility')}
|
||||||
description={
|
description={
|
||||||
!hasAssignees
|
!hasAssignees
|
||||||
? 'Assign someone to enable limited visibility'
|
? t('advancedOptions.limitedVisibilityDisabled')
|
||||||
: 'Only you and assignees can see this task'
|
: t('advancedOptions.limitedVisibilityDescription')
|
||||||
}
|
}
|
||||||
onLabelClick={
|
onLabelClick={
|
||||||
hasAssignees ? () => onIsPrivateChange(!isPrivate) : undefined
|
hasAssignees ? () => onIsPrivateChange(!isPrivate) : undefined
|
||||||
@@ -272,8 +275,8 @@ const AdvancedOptionsSection = ({
|
|||||||
{/* Assignment strategy — only shown when there are multiple assignees */}
|
{/* Assignment strategy — only shown when there are multiple assignees */}
|
||||||
{hasMultipleAssignees && (
|
{hasMultipleAssignees && (
|
||||||
<FieldRow
|
<FieldRow
|
||||||
label='Assign strategy'
|
label={t('advancedOptions.assignStrategy')}
|
||||||
description='How to pick the next assignee each recurrence'
|
description={t('advancedOptions.assignStrategyDescription')}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -281,9 +284,9 @@ const AdvancedOptionsSection = ({
|
|||||||
onChange={(_, v) => onAssignStrategyChange(v)}
|
onChange={(_, v) => onAssignStrategyChange(v)}
|
||||||
sx={{ minWidth: 190 }}
|
sx={{ minWidth: 190 }}
|
||||||
>
|
>
|
||||||
{STRATEGY_OPTIONS.map(opt => (
|
{STRATEGY_VALUES.map(opt => (
|
||||||
<Option key={opt.value} value={opt.value}>
|
<Option key={opt} value={opt}>
|
||||||
{opt.label}
|
{t(`advancedOptions.strategy.${opt}`)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -294,8 +297,8 @@ const AdvancedOptionsSection = ({
|
|||||||
{hasDueDate ? (
|
{hasDueDate ? (
|
||||||
<>
|
<>
|
||||||
<FieldRow
|
<FieldRow
|
||||||
label='Available from'
|
label={t('advancedOptions.availableFrom')}
|
||||||
description='Hours before the due date the task becomes available'
|
description={t('advancedOptions.availableFromDescription')}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
type='number'
|
||||||
@@ -308,7 +311,7 @@ const AdvancedOptionsSection = ({
|
|||||||
}}
|
}}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
<Typography level='body-xs' textColor='text.tertiary'>
|
<Typography level='body-xs' textColor='text.tertiary'>
|
||||||
hrs
|
{t('advancedOptions.hrs')}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
sx={{ width: 96 }}
|
sx={{ width: 96 }}
|
||||||
@@ -322,7 +325,7 @@ const AdvancedOptionsSection = ({
|
|||||||
textColor='text.tertiary'
|
textColor='text.tertiary'
|
||||||
sx={{ my: 0.5, fontStyle: 'italic' }}
|
sx={{ my: 0.5, fontStyle: 'italic' }}
|
||||||
>
|
>
|
||||||
Set a due date to configure completion window
|
{t('advancedOptions.setDueDateHint')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Calendar from 'react-calendar'
|
import Calendar from 'react-calendar'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import ModalActions from '../../components/common/ModalActions'
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
@@ -93,16 +94,17 @@ const toDateKey = date => moment(date).format('YYYY-MM-DD')
|
|||||||
* reschedule a task — task cards, swipe actions, action menus.
|
* reschedule a task — task cards, swipe actions, action menus.
|
||||||
*/
|
*/
|
||||||
const DueDatePickerModal = ({
|
const DueDatePickerModal = ({
|
||||||
applyLabel = 'Apply',
|
applyLabel,
|
||||||
dueDateOnly,
|
dueDateOnly,
|
||||||
dueTime,
|
dueTime,
|
||||||
onApply,
|
onApply,
|
||||||
onClose,
|
onClose,
|
||||||
onRemove,
|
onRemove,
|
||||||
open,
|
open,
|
||||||
title = 'Due Date',
|
title,
|
||||||
useCustomTime,
|
useCustomTime,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
const { firstDayOfWeek } = useLocalization()
|
const { firstDayOfWeek } = useLocalization()
|
||||||
|
|
||||||
@@ -172,21 +174,24 @@ const DueDatePickerModal = ({
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={title}
|
title={title ?? t('duePicker.title')}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
footer={
|
footer={
|
||||||
<ModalActions
|
<ModalActions
|
||||||
tertiary={
|
tertiary={
|
||||||
onRemove && dueDateOnly
|
onRemove && dueDateOnly
|
||||||
? {
|
? {
|
||||||
label: 'Remove',
|
label: t('duePicker.remove'),
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
onClick: onRemove,
|
onClick: onRemove,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
secondary={{ label: t('common:cancel'), onClick: onClose }}
|
||||||
primary={{ label: applyLabel, onClick: handleSave }}
|
primary={{
|
||||||
|
label: applyLabel ?? t('duePicker.apply'),
|
||||||
|
onClick: handleSave,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -201,33 +206,33 @@ const DueDatePickerModal = ({
|
|||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Quick date
|
{t('duePicker.quickDate')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
key: 'today',
|
key: 'today',
|
||||||
label: 'Today',
|
label: t('duePicker.today'),
|
||||||
icon: <Today sx={{ fontSize: 14 }} />,
|
icon: <Today sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tomorrow',
|
key: 'tomorrow',
|
||||||
label: 'Tomorrow',
|
label: t('duePicker.tomorrow'),
|
||||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'weekend',
|
key: 'weekend',
|
||||||
label: 'Weekend',
|
label: t('duePicker.weekend'),
|
||||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'next-week',
|
key: 'next-week',
|
||||||
label: 'Next week',
|
label: t('duePicker.nextWeek'),
|
||||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'next-month',
|
key: 'next-month',
|
||||||
label: 'Next month',
|
label: t('duePicker.nextMonth'),
|
||||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
].map(opt => {
|
].map(opt => {
|
||||||
@@ -268,33 +273,33 @@ const DueDatePickerModal = ({
|
|||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Quick time
|
{t('duePicker.quickTime')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
time: '09:00',
|
time: '09:00',
|
||||||
label: 'Morning',
|
label: t('duePicker.morning'),
|
||||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
time: '12:00',
|
time: '12:00',
|
||||||
label: 'Noon',
|
label: t('duePicker.noon'),
|
||||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
time: '15:00',
|
time: '15:00',
|
||||||
label: 'Afternoon',
|
label: t('duePicker.afternoon'),
|
||||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
time: '18:00',
|
time: '18:00',
|
||||||
label: 'Evening',
|
label: t('duePicker.evening'),
|
||||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
time: '22:00',
|
time: '22:00',
|
||||||
label: 'Night',
|
label: t('duePicker.night'),
|
||||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||||
},
|
},
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
@@ -506,7 +511,7 @@ const DueDatePickerModal = ({
|
|||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Custom time
|
{t('duePicker.customTime')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Input
|
<Input
|
||||||
type='time'
|
type='time'
|
||||||
@@ -525,7 +530,7 @@ const DueDatePickerModal = ({
|
|||||||
disabled={!localDueDateOnly}
|
disabled={!localDueDateOnly}
|
||||||
onClick={() => setLocalUseCustomTime(false)}
|
onClick={() => setLocalUseCustomTime(false)}
|
||||||
>
|
>
|
||||||
Anytime
|
{t('duePicker.anytime')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -534,7 +539,7 @@ const DueDatePickerModal = ({
|
|||||||
disabled={!localDueDateOnly}
|
disabled={!localDueDateOnly}
|
||||||
onClick={() => setLocalUseCustomTime(true)}
|
onClick={() => setLocalUseCustomTime(true)}
|
||||||
>
|
>
|
||||||
Specific time
|
{t('duePicker.specificTime')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
|||||||
{phase === 'preview' && capturedImage && (
|
{phase === 'preview' && capturedImage && (
|
||||||
<img
|
<img
|
||||||
src={capturedImage}
|
src={capturedImage}
|
||||||
alt='Captured document'
|
alt={t('photoTask.altCaptured')}
|
||||||
style={{ width: '100%', display: 'block' }}
|
style={{ width: '100%', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -372,7 +372,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
|||||||
{capturedImage && (
|
{capturedImage && (
|
||||||
<img
|
<img
|
||||||
src={capturedImage}
|
src={capturedImage}
|
||||||
alt='Processing'
|
alt={t('photoTask.altProcessing')}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -408,7 +408,9 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CheckCircle color='success' />
|
<CheckCircle color='success' />
|
||||||
<Typography level='title-sm'>Task identified</Typography>
|
<Typography level='title-sm'>
|
||||||
|
{t('photoTask.taskIdentified')}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -531,7 +533,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
|||||||
startDecorator={<Replay />}
|
startDecorator={<Replay />}
|
||||||
onClick={handleRetake}
|
onClick={handleRetake}
|
||||||
>
|
>
|
||||||
Retake
|
{t('photoTask.retake')}
|
||||||
</Button>
|
</Button>
|
||||||
{isNativeScanner &&
|
{isNativeScanner &&
|
||||||
capturedImage &&
|
capturedImage &&
|
||||||
@@ -570,7 +572,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
|||||||
startDecorator={<Replay />}
|
startDecorator={<Replay />}
|
||||||
onClick={handleRetake}
|
onClick={handleRetake}
|
||||||
>
|
>
|
||||||
Retake
|
{t('photoTask.retake')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -591,7 +593,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
|||||||
startDecorator={<Replay />}
|
startDecorator={<Replay />}
|
||||||
onClick={handleRetake}
|
onClick={handleRetake}
|
||||||
>
|
>
|
||||||
Retake
|
{t('photoTask.retake')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='solid' color='primary' onClick={handleConfirm}>
|
<Button variant='solid' color='primary' onClick={handleConfirm}>
|
||||||
{t('photoTask.createTask')}
|
{t('photoTask.createTask')}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import ModalActions from '../../components/common/ModalActions'
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
@@ -54,13 +55,10 @@ const MONTHS = [
|
|||||||
'december',
|
'december',
|
||||||
]
|
]
|
||||||
|
|
||||||
const OCCURRENCE_OPTIONS = [
|
const OCCURRENCE_VALUES = [1, 2, 3, 4, -1]
|
||||||
{ value: 1, label: '1st' },
|
|
||||||
{ value: 2, label: '2nd' },
|
// -1 is the "last occurrence" sentinel; everything else keys off its number.
|
||||||
{ value: 3, label: '3rd' },
|
const occurrenceKey = value => (value === -1 ? 'last' : String(value))
|
||||||
{ value: 4, label: '4th' },
|
|
||||||
{ value: -1, label: 'Last' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const defaultMetadata = () => ({
|
const defaultMetadata = () => ({
|
||||||
unit: 'days',
|
unit: 'days',
|
||||||
@@ -119,26 +117,29 @@ const SectionLabel = ({ children }) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Shared time-of-day picker
|
// Shared time-of-day picker
|
||||||
const TimeRow = ({ metadata, onUpdate }) => (
|
const TimeRow = ({ metadata, onUpdate }) => {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
const { t } = useTranslation('chores')
|
||||||
<SectionLabel>Time of day</SectionLabel>
|
return (
|
||||||
<Input
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||||
type='time'
|
<SectionLabel>{t('repeat.picker.timeOfDay')}</SectionLabel>
|
||||||
size='sm'
|
<Input
|
||||||
value={moment(metadata?.time).format('HH:mm')}
|
type='time'
|
||||||
onChange={e =>
|
size='sm'
|
||||||
onUpdate({
|
value={moment(metadata?.time).format('HH:mm')}
|
||||||
...metadata,
|
onChange={e =>
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
onUpdate({
|
||||||
time: moment(
|
...metadata,
|
||||||
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
).format(),
|
time: moment(
|
||||||
})
|
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
|
||||||
}
|
).format(),
|
||||||
sx={{ width: 120 }}
|
})
|
||||||
/>
|
}
|
||||||
</Box>
|
sx={{ width: 120 }}
|
||||||
)
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const pillListSx = {
|
const pillListSx = {
|
||||||
'--List-gap': '8px',
|
'--List-gap': '8px',
|
||||||
@@ -151,51 +152,55 @@ const IntervalSection = ({
|
|||||||
frequencyMetadata,
|
frequencyMetadata,
|
||||||
onFrequencyMetadataUpdate,
|
onFrequencyMetadataUpdate,
|
||||||
onFrequencyUpdate,
|
onFrequencyUpdate,
|
||||||
}) => (
|
}) => {
|
||||||
<Box>
|
const { t } = useTranslation('chores')
|
||||||
<SectionLabel>Repeat every</SectionLabel>
|
return (
|
||||||
<Box
|
<Box>
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
<SectionLabel>{t('repeat.picker.repeatEvery')}</SectionLabel>
|
||||||
>
|
<Box
|
||||||
<Input
|
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||||
type='number'
|
>
|
||||||
size='sm'
|
<Input
|
||||||
value={frequency}
|
type='number'
|
||||||
onChange={e =>
|
size='sm'
|
||||||
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
|
value={frequency}
|
||||||
}
|
onChange={e =>
|
||||||
sx={{ width: 72 }}
|
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
|
||||||
slotProps={{ input: { min: 1, max: 999 } }}
|
}
|
||||||
|
sx={{ width: 72 }}
|
||||||
|
slotProps={{ input: { min: 1, max: 999 } }}
|
||||||
|
/>
|
||||||
|
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||||
|
{['days', 'weeks', 'months', 'years'].map(unit => (
|
||||||
|
<ListItem key={unit}>
|
||||||
|
<Checkbox
|
||||||
|
checked={frequencyMetadata?.unit === unit}
|
||||||
|
onClick={() =>
|
||||||
|
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
|
||||||
|
}
|
||||||
|
overlay
|
||||||
|
disableIcon
|
||||||
|
variant='soft'
|
||||||
|
label={t(`repeat.unit.${unit}`)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
<TimeRow
|
||||||
|
metadata={frequencyMetadata}
|
||||||
|
onUpdate={onFrequencyMetadataUpdate}
|
||||||
/>
|
/>
|
||||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
|
||||||
{['days', 'weeks', 'months', 'years'].map(unit => (
|
|
||||||
<ListItem key={unit}>
|
|
||||||
<Checkbox
|
|
||||||
checked={frequencyMetadata?.unit === unit}
|
|
||||||
onClick={() =>
|
|
||||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
|
|
||||||
}
|
|
||||||
overlay
|
|
||||||
disableIcon
|
|
||||||
variant='soft'
|
|
||||||
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Box>
|
</Box>
|
||||||
<TimeRow
|
)
|
||||||
metadata={frequencyMetadata}
|
}
|
||||||
onUpdate={onFrequencyMetadataUpdate}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Days of week section
|
// Days of week section
|
||||||
const DaysOfWeekSection = ({
|
const DaysOfWeekSection = ({
|
||||||
frequencyMetadata,
|
frequencyMetadata,
|
||||||
onFrequencyMetadataUpdate,
|
onFrequencyMetadataUpdate,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const selectedDays = frequencyMetadata?.days || []
|
const selectedDays = frequencyMetadata?.days || []
|
||||||
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
|
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
|
||||||
const selectedOccurrences = frequencyMetadata?.occurrences || []
|
const selectedOccurrences = frequencyMetadata?.occurrences || []
|
||||||
@@ -216,7 +221,7 @@ const DaysOfWeekSection = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<SectionLabel>Days</SectionLabel>
|
<SectionLabel>{t('repeat.picker.days')}</SectionLabel>
|
||||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||||
{DAYS.map(day => (
|
{DAYS.map(day => (
|
||||||
<ListItem key={day}>
|
<ListItem key={day}>
|
||||||
@@ -226,14 +231,14 @@ const DaysOfWeekSection = ({
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
|
label={t(`repeat.daysShort.${day}`)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<SectionLabel>Pattern</SectionLabel>
|
<SectionLabel>{t('repeat.picker.pattern')}</SectionLabel>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
orientation='horizontal'
|
orientation='horizontal'
|
||||||
value={weekPattern}
|
value={weekPattern}
|
||||||
@@ -255,15 +260,15 @@ const DaysOfWeekSection = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ value: 'every_week', label: 'Every week' },
|
{ value: 'every_week', labelKey: 'everyWeek' },
|
||||||
{ value: 'week_of_month', label: 'Specific weeks' },
|
{ value: 'week_of_month', labelKey: 'specificWeeks' },
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<Radio
|
<Radio
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
color='neutral'
|
color='neutral'
|
||||||
disableIcon
|
disableIcon
|
||||||
label={opt.label}
|
label={t(`repeat.picker.${opt.labelKey}`)}
|
||||||
variant='plain'
|
variant='plain'
|
||||||
sx={{ px: 1.5, py: 0.5 }}
|
sx={{ px: 1.5, py: 0.5 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -284,17 +289,17 @@ const DaysOfWeekSection = ({
|
|||||||
|
|
||||||
{weekPattern === 'week_of_month' && (
|
{weekPattern === 'week_of_month' && (
|
||||||
<Box sx={{ mt: 1.5 }}>
|
<Box sx={{ mt: 1.5 }}>
|
||||||
<SectionLabel>Occurrences</SectionLabel>
|
<SectionLabel>{t('repeat.picker.occurrences')}</SectionLabel>
|
||||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||||
{OCCURRENCE_OPTIONS.map(opt => (
|
{OCCURRENCE_VALUES.map(opt => (
|
||||||
<ListItem key={opt.value}>
|
<ListItem key={opt}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedOccurrences.includes(opt.value)}
|
checked={selectedOccurrences.includes(opt)}
|
||||||
onClick={() => toggleOccurrence(opt.value)}
|
onClick={() => toggleOccurrence(opt)}
|
||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={opt.label}
|
label={t(`repeat.occurrenceShort.${occurrenceKey(opt)}`)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -317,6 +322,7 @@ const DayOfMonthSection = ({
|
|||||||
onFrequencyMetadataUpdate,
|
onFrequencyMetadataUpdate,
|
||||||
onFrequencyUpdate,
|
onFrequencyUpdate,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
const selectedMonths = frequencyMetadata?.months || []
|
const selectedMonths = frequencyMetadata?.months || []
|
||||||
|
|
||||||
const toggleMonth = month => {
|
const toggleMonth = month => {
|
||||||
@@ -328,7 +334,7 @@ const DayOfMonthSection = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<SectionLabel>Months</SectionLabel>
|
<SectionLabel>{t('repeat.picker.months')}</SectionLabel>
|
||||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||||
{MONTHS.map(month => (
|
{MONTHS.map(month => (
|
||||||
<ListItem key={month}>
|
<ListItem key={month}>
|
||||||
@@ -338,14 +344,14 @@ const DayOfMonthSection = ({
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
|
label={t(`repeat.monthsShort.${month}`)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||||
<SectionLabel>Day of month</SectionLabel>
|
<SectionLabel>{t('repeat.picker.dayOfMonth')}</SectionLabel>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
type='number'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -383,6 +389,7 @@ const RepeatPickerField = ({
|
|||||||
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
|
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
|
||||||
useState(defaultMetadata)
|
useState(defaultMetadata)
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
const { t } = useTranslation('chores')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -394,7 +401,9 @@ const RepeatPickerField = ({
|
|||||||
|
|
||||||
const hasRepeat = Boolean(value)
|
const hasRepeat = Boolean(value)
|
||||||
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
|
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 displayType = getDisplayType(localFrequencyType)
|
||||||
|
|
||||||
const handleTypeSelect = type => {
|
const handleTypeSelect = type => {
|
||||||
@@ -474,7 +483,7 @@ const RepeatPickerField = ({
|
|||||||
|
|
||||||
{hasRepeat && onClear && (
|
{hasRepeat && onClear && (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='Clear repeat schedule'
|
aria-label={t('repeat.picker.clearAria')}
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='danger'
|
color='danger'
|
||||||
@@ -499,13 +508,13 @@ const RepeatPickerField = ({
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
title='Repeat Schedule'
|
title={t('repeat.picker.modalTitle')}
|
||||||
footer={
|
footer={
|
||||||
<ModalActions
|
<ModalActions
|
||||||
tertiary={
|
tertiary={
|
||||||
hasRepeat
|
hasRepeat
|
||||||
? {
|
? {
|
||||||
label: 'Remove',
|
label: t('repeat.picker.remove'),
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onClear?.()
|
onClear?.()
|
||||||
@@ -514,15 +523,18 @@ const RepeatPickerField = ({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
secondary={{
|
||||||
primary={{ label: 'Apply', onClick: handleSave }}
|
label: t('common:cancel'),
|
||||||
|
onClick: () => setIsOpen(false),
|
||||||
|
}}
|
||||||
|
primary={{ label: t('repeat.picker.apply'), onClick: handleSave }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Frequency type selector */}
|
{/* Frequency type selector */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<SectionLabel>Frequency</SectionLabel>
|
<SectionLabel>{t('repeat.picker.frequency')}</SectionLabel>
|
||||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||||
{FREQUENCY_TYPES.map(type => (
|
{FREQUENCY_TYPES.map(type => (
|
||||||
<ListItem key={type}>
|
<ListItem key={type}>
|
||||||
@@ -532,7 +544,7 @@ const RepeatPickerField = ({
|
|||||||
overlay
|
overlay
|
||||||
disableIcon
|
disableIcon
|
||||||
variant='soft'
|
variant='soft'
|
||||||
label={type.charAt(0).toUpperCase() + type.slice(1)}
|
label={t(`repeat.freqType.${type}`)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -543,7 +555,7 @@ const RepeatPickerField = ({
|
|||||||
{displayType === 'custom' && (
|
{displayType === 'custom' && (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
<SectionLabel>Schedule type</SectionLabel>
|
<SectionLabel>{t('repeat.picker.scheduleType')}</SectionLabel>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
orientation='horizontal'
|
orientation='horizontal'
|
||||||
value={localFrequencyType}
|
value={localFrequencyType}
|
||||||
@@ -563,14 +575,7 @@ const RepeatPickerField = ({
|
|||||||
value={type}
|
value={type}
|
||||||
color='neutral'
|
color='neutral'
|
||||||
disableIcon
|
disableIcon
|
||||||
label={type
|
label={t(`repeat.repeatOnType.${type}`)}
|
||||||
.split('_')
|
|
||||||
.map((w, i, arr) =>
|
|
||||||
i === 0 || i === arr.length - 1
|
|
||||||
? w.charAt(0).toUpperCase() + w.slice(1)
|
|
||||||
: w,
|
|
||||||
)
|
|
||||||
.join(' ')}
|
|
||||||
variant='plain'
|
variant='plain'
|
||||||
sx={{ px: 1.5, py: 0.5 }}
|
sx={{ px: 1.5, py: 0.5 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const ScanPanel = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (phase === 'error') {
|
if (phase === 'error') {
|
||||||
return { label: 'Retake', icon: <Replay />, onClick: retake }
|
return { label: t('photoTask.retake'), icon: <Replay />, onClick: retake }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}, [
|
}, [
|
||||||
@@ -160,7 +160,7 @@ const ScanPanel = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
checked={keepImage}
|
checked={keepImage}
|
||||||
onChange={e => setKeepImage(e.target.checked)}
|
onChange={e => setKeepImage(e.target.checked)}
|
||||||
label='Keep photo as attachment'
|
label={t('photoTask.keepAsAttachment')}
|
||||||
sx={{ '--Checkbox-size': '18px' }}
|
sx={{ '--Checkbox-size': '18px' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -248,7 +248,7 @@ const ScanPanel = ({
|
|||||||
startDecorator={<PhotoCamera fontSize='small' />}
|
startDecorator={<PhotoCamera fontSize='small' />}
|
||||||
onClick={openFilePicker}
|
onClick={openFilePicker}
|
||||||
>
|
>
|
||||||
Upload
|
{t('photoTask.upload')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{keepImageToggle}
|
{keepImageToggle}
|
||||||
@@ -278,7 +278,7 @@ const ScanPanel = ({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={capturedImage}
|
src={capturedImage}
|
||||||
alt='Processing'
|
alt={t('photoTask.altProcessing')}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
|
|||||||
Reference in New Issue
Block a user