From d708f9c69afd8d40bd04733b6b9ce323a7d11abb Mon Sep 17 00:00:00 2001 From: mschabhuettl Date: Thu, 30 Jul 2026 02:28:15 +0200 Subject: [PATCH] feat(settings): add 'Import from BeTidy' screen Adds a Settings page (Settings -> Import from BeTidy) to upload a BeTidy export bundle and import it into Donetick via the new POST /chores/import/betidy endpoint. Includes file upload, timezone and include-inactive options, a result summary, and English i18n strings. Requires backend donetick/donetick#756. Proposed in discussion donetick/donetick#754. --- public/locales/en/settings.json | 22 +++ src/contexts/RouterContext.jsx | 5 + src/utils/Fetcher.jsx | 10 + src/views/Settings/BeTidyImportSettings.jsx | 202 ++++++++++++++++++++ src/views/Settings/SettingsOverview.jsx | 7 + 5 files changed, 246 insertions(+) create mode 100644 src/views/Settings/BeTidyImportSettings.jsx diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index d03ef6d7..29036c52 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -168,7 +168,29 @@ "developer": { "title": "Developer Settings", "description": "View technical information about authentication tokens, SSE connections, and debug data." + }, + "importBetidy": { + "title": "Import from BeTidy", + "description": "Migrate your tasks from the BeTidy app" } } + }, + "betidyImport": { + "title": "Import from BeTidy", + "description": "Upload a data bundle exported from the BeTidy app with the community tool", + "fileLabel": "Export bundle (betidy_export.json)", + "chooseFile": "Choose file", + "taskCount": "{{count}} tasks", + "timezone": "Timezone", + "includeInactive": "Also import inactive / finished tasks", + "import": "Import", + "invalidFile": "Invalid file", + "invalidFileHint": "Please select a valid betidy_export.json file.", + "successTitle": "Import complete", + "successMessage": "Imported {{count}} chores.", + "errorTitle": "Import failed", + "resultTitle": "Import complete", + "resultSummary": "Imported {{imported}}, skipped {{skipped}}, labels created {{labels}}.", + "errorsSuffix": "task(s) could not be imported (see server logs)." } } diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 906a6fb8..235d0fa1 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -3,6 +3,7 @@ import ChoreEdit from '@/views/ChoreEdit/ChoreEdit' import Error from '@/views/Error' import AccountSettings from '@/views/Settings/AccountSettings' import AdvancedSettings from '@/views/Settings/AdvancedSettings' +import BeTidyImportSettings from '@/views/Settings/BeTidyImportSettings' import ChildUserSettings from '@/views/Settings/ChildUserSettings' import CircleSettings from '@/views/Settings/CircleSettings' import DeveloperSettings from '@/views/Settings/DeveloperSettings' @@ -128,6 +129,10 @@ const Router = createBrowserRouter([ path: 'developer', element: , }, + { + path: 'import-betidy', + element: , + }, ], }, { diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 65375e71..9f75d633 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -975,8 +975,18 @@ const TrackFilterUsage = id => { }) } +// Import a BeTidy export bundle (see github.com/mschabhuettl/betidy-export). +const ImportBeTidy = bundle => { + return Fetch(`/chores/import/betidy`, { + method: 'POST', + headers: HEADERS(), + body: JSON.stringify(bundle), + }) +} + export { AcceptCircleMemberRequest, + ImportBeTidy, DeleteChoreAttachment, DeleteDraftAttachment, GetChoreAttachments, diff --git a/src/views/Settings/BeTidyImportSettings.jsx b/src/views/Settings/BeTidyImportSettings.jsx new file mode 100644 index 00000000..bd1890d9 --- /dev/null +++ b/src/views/Settings/BeTidyImportSettings.jsx @@ -0,0 +1,202 @@ +import { CloudUpload } from '@mui/icons-material' +import { + Alert, + Box, + Button, + Card, + Checkbox, + Chip, + Divider, + FormControl, + FormLabel, + Input, + Link, + Typography, +} from '@mui/joy' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNotification } from '../../service/NotificationProvider' +import { ImportBeTidy } from '../../utils/Fetcher' +import SettingsLayout from './SettingsLayout' + +const guessTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + } catch { + return 'UTC' + } +} + +const BeTidyImportSettings = () => { + const { t } = useTranslation('settings') + const { showNotification } = useNotification() + + const [bundle, setBundle] = useState(null) + const [fileName, setFileName] = useState('') + const [taskCount, setTaskCount] = useState(0) + const [timezone, setTimezone] = useState(guessTimezone()) + const [includeInactive, setIncludeInactive] = useState(false) + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + + const handleFile = event => { + const file = event.target.files?.[0] + if (!file) return + setResult(null) + const reader = new FileReader() + reader.onload = e => { + try { + const parsed = JSON.parse(e.target.result) + if (!parsed || !Array.isArray(parsed.tasks)) { + throw new Error('missing tasks') + } + setBundle(parsed) + setFileName(file.name) + setTaskCount(parsed.tasks.length) + } catch { + setBundle(null) + setFileName('') + setTaskCount(0) + showNotification({ + type: 'error', + title: t('betidyImport.invalidFile'), + message: t('betidyImport.invalidFileHint'), + }) + } + } + reader.readAsText(file) + } + + const handleImport = async () => { + if (!bundle) return + setLoading(true) + setResult(null) + try { + const response = await ImportBeTidy({ + ...bundle, + timezone, + includeInactive, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const data = await response.json() + const res = data.res || data + setResult(res) + showNotification({ + type: 'success', + title: t('betidyImport.successTitle'), + message: t('betidyImport.successMessage', { count: res.imported }), + }) + } catch (error) { + showNotification({ + type: 'error', + title: t('betidyImport.errorTitle'), + message: String(error?.message || error), + }) + } finally { + setLoading(false) + } + } + + return ( + + + + {t('betidyImport.description')}{' '} + + betidy-export + + . + + + + + {t('betidyImport.fileLabel')} + + {fileName && ( + + {fileName}{' '} + + {t('betidyImport.taskCount', { count: taskCount })} + + + )} + + + + + + {t('betidyImport.timezone')} + setTimezone(e.target.value)} + placeholder='UTC' + sx={{ maxWidth: 320 }} + /> + + + setIncludeInactive(e.target.checked)} + /> + + + + + {result && ( + + + + {t('betidyImport.resultTitle')} + + + {t('betidyImport.resultSummary', { + imported: result.imported, + skipped: result.skipped, + labels: result.labelsCreated, + })} + + {Array.isArray(result.errors) && result.errors.length > 0 && ( + + {result.errors.length} {t('betidyImport.errorsSuffix')} + + )} + + + )} + + + ) +} + +export default BeTidyImportSettings diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx index eadb8f9a..9cf8c48e 100644 --- a/src/views/Settings/SettingsOverview.jsx +++ b/src/views/Settings/SettingsOverview.jsx @@ -4,6 +4,7 @@ import { ChevronRight, Circle, Code, + Download, FamilyRestroom, Language, Notifications, @@ -122,6 +123,12 @@ const SettingsOverview = () => { description: t('overview.sections.developer.description'), icon: , }, + { + id: 'import-betidy', + title: t('overview.sections.importBetidy.title'), + description: t('overview.sections.importBetidy.description'), + icon: , + }, ] const handleCardClick = settingId => {