From 2fd6d5cd2eb17525f425bdaf4daf8f1a2f9558eb Mon Sep 17 00:00:00 2001 From: tanomitsu <64388900+tanomitsu@users.noreply.github.com> Date: Tue, 26 May 2026 15:28:29 +0900 Subject: [PATCH 1/9] feat: add Japanese (ja) localization - Add ja translations for common, settings, and chores namespaces - Register ja in AVAILABLE_LANGUAGES so it appears in the language picker - Import moment/locale/ja so date and relative-time formatting uses Japanese --- public/locales/ja/chores.json | 75 ++++++++++++ public/locales/ja/common.json | 33 +++++ public/locales/ja/settings.json | 174 +++++++++++++++++++++++++++ src/contexts/LocalizationContext.jsx | 1 + src/i18n/config.js | 1 + 5 files changed, 284 insertions(+) create mode 100644 public/locales/ja/chores.json create mode 100644 public/locales/ja/common.json create mode 100644 public/locales/ja/settings.json diff --git a/public/locales/ja/chores.json b/public/locales/ja/chores.json new file mode 100644 index 0000000..cb1dac3 --- /dev/null +++ b/public/locales/ja/chores.json @@ -0,0 +1,75 @@ +{ + "title": "タスク", + "myChores": "自分のタスク", + "allChores": "すべてのタスク", + "addChore": "タスクを追加", + "editChore": "タスクを編集", + "deleteChore": "タスクを削除", + "completeChore": "タスクを完了", + "dueDate": "期限", + "assignedTo": "担当者", + "priority": "優先度", + "status": "ステータス", + "description": "説明", + "choreView": { + "assignment": "担当", + "assigned": "担当者", + "last": "前回", + "schedule": "スケジュール", + "due": "期限", + "statistics": "統計", + "completed": "完了", + "times": "回", + "details": "詳細", + "createdBy": "作成者", + "na": "該当なし", + "taskCompleted": "タスク完了", + "taskCompletedMessage": "タスクを完了としてマークしました", + "taskCompletionUndone": "タスクの完了を取り消しました。", + "taskSkipUndone": "タスクのスキップを取り消しました。", + "undoSuccessful": "取り消し成功", + "undoFailed": "取り消し失敗", + "undoFailedMessage": "操作を取り消せませんでした。もう一度お試しください。", + "resetTimer": "タイマーをリセット", + "resetTimerConfirmation": "本当にタイマーをリセットしますか?このタスクを開始してからのすべての時間記録が削除されます。", + "clearAllTimeRecords": "すべての時間記録を削除", + "clearAllTimeConfirmation": "このタスクのすべてのタイマーを完全に削除し、「未開始」の状態に戻します。", + "descriptionTitle": "説明", + "description": "説明 :", + "previousNote": "前回のメモ", + "previousNoteLabel": "前回のメモ:", + "subtasksLabel": "サブタスク :", + "taskActions": "タスク操作", + "addNote": "メモを追加", + "additionalNotes": "追加メモ:", + "notePlaceholder": "完了についてのメモを追加...", + "setCustomCompletionTime": "完了時刻をカスタム指定", + "skipTask": "タスクをスキップ", + "skipTaskConfirmation": "本当にこのタスクをスキップしますか?", + "markComplete": "完了としてマーク", + "markAsDone": "完了にする", + "edit": "編集", + "archive": "アーカイブ", + "unarchive": "アーカイブ解除", + "viewHistory": "履歴を表示", + "history": "履歴", + "startTimer": "タイマー開始", + "start": "開始", + "pauseTimer": "タイマー一時停止", + "approve": "承認", + "reject": "却下", + "pendingApproval": "承認待ち", + "undo": "取り消し", + "skip": "スキップ", + "cancel": "キャンセル", + "noPriority": "優先度なし", + "subtasks": "サブタスク", + "noDescription": "説明はありません", + "timer": { + "active": "タイマー動作中", + "paused": "タイマー一時停止中", + "reset": "タイマーをリセット", + "delete": "セッションを削除" + } + } +} diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json new file mode 100644 index 0000000..9b96c62 --- /dev/null +++ b/public/locales/ja/common.json @@ -0,0 +1,33 @@ +{ + "save": "保存", + "cancel": "キャンセル", + "delete": "削除", + "edit": "編集", + "close": "閉じる", + "confirm": "確認", + "loading": "読み込み中...", + "error": "エラー", + "success": "成功", + "warning": "警告", + "refresh": "更新", + "copy": "コピー", + "copied": "コピーしました!", + "settings": "設定", + "yes": "はい", + "no": "いいえ", + "back": "戻る", + "backToCalendar": "カレンダーに戻る", + "logout": "ログアウト", + "version": "バージョン", + "navigation": { + "allTasks": "すべてのタスク", + "archived": "アーカイブ済み", + "things": "Things", + "labels": "ラベル", + "projects": "プロジェクト", + "filters": "フィルター", + "activities": "アクティビティ", + "points": "ポイント", + "settings": "設定" + } +} diff --git a/public/locales/ja/settings.json b/public/locales/ja/settings.json new file mode 100644 index 0000000..75581d5 --- /dev/null +++ b/public/locales/ja/settings.json @@ -0,0 +1,174 @@ +{ + "title": "設定", + "circleSettings": { + "title": "サークル設定", + "description": "アカウントは、サークルを作成または参加すると自動的にそのサークルに接続されます。下記のユニークなサークルコードまたはリンクを共有することで、友達を簡単に招待できます。誰かがサークルへの参加をリクエストした際は、下に通知が表示されます。サークルから脱退したい場合は、「サークルを脱退」ボタンを押してください。", + "circleCode": "サークルコード", + "copyCode": "コードをコピー", + "copyLink": "リンクをコピー", + "codeCopied": "サークルコードをコピーしました!", + "linkCopied": "サークルリンクをコピーしました!", + "joinCircle": "サークルに参加", + "joinCirclePlaceholder": "サークルコードを入力", + "join": "参加", + "leave": "サークルを脱退", + "leaveConfirmTitle": "サークルを脱退", + "leaveConfirmMessage": "本当にこのサークルから脱退しますか?", + "circleMembers": "サークルメンバー", + "circleMemberRequests": "サークル参加リクエスト", + "admin": "管理者", + "member": "メンバー", + "pending": "保留中", + "accept": "承認", + "reject": "拒否", + "makeAdmin": "管理者にする", + "makeMember": "メンバーにする", + "remove": "削除", + "webhookURL": "Webhook URL", + "webhookDescription": "サークルイベントの通知を受け取るための Webhook URL を入力してください", + "webhookPlaceholder": "https://your-webhook-url.com" + }, + "accountSettings": { + "title": "アカウント設定", + "subscription": "サブスクリプション", + "subscriptionStatus": "現在のプラン", + "free": "無料", + "plus": "Plus", + "upgrade": "アップグレード", + "cancel": "キャンセル", + "changePassword": "パスワードを変更", + "password": "パスワード", + "dangerZone": "危険な操作", + "dangerZoneDescription": "アカウントを削除すると元に戻すことはできません。十分にご確認ください。", + "deleteAccount": "アカウントを削除" + }, + "localization": { + "title": "地域設定", + "description": "言語、日付形式、地域の設定をカスタマイズします。", + "language": "言語", + "languageDescription": "使用する言語を選択してください", + "dateFormat": "日付形式", + "dateFormatDescription": "アプリケーション全体で日付をどのように表示するかを選択します", + "timeFormat": "時刻形式", + "timeFormatDescription": "12時間表記または24時間表記を選択します", + "12hour": "12時間 (AM/PM)", + "24hour": "24時間", + "firstDayOfWeek": "週の開始曜日", + "firstDayOfWeekDescription": "週の最初の曜日を選択します", + "sunday": "日曜日", + "monday": "月曜日", + "saturday": "土曜日", + "formats": { + "mdy": "MM/DD/YYYY (米国)", + "dmy": "DD/MM/YYYY (欧州)", + "ymd": "YYYY-MM-DD (ISO)", + "long": "長い形式 (例: January 1, 2024)", + "short": "短い形式 (例: Jan 1, 2024)" + } + }, + "sidepanel": { + "title": "サイドパネルのカスタマイズ", + "description": "サイドパネル内のカードのレイアウトと表示をカスタマイズします。この機能はタブレットやデスクトップなど大画面のデバイスでのみ利用できます。" + }, + "theme": { + "title": "テーマ設定", + "description": "サイトの見た目を選択します。単一のテーマを選ぶか、システム設定に同期して昼夜のテーマを自動で切り替えることができます。", + "themeMode": "テーマモード", + "light": "ライト", + "dark": "ダーク", + "system": "システム" + }, + "notifications": { + "settingsSaved": "設定を保存しました", + "settingsSaveFailed": "設定の保存に失敗しました", + "invalidWebhook": "Webhook URL が無効です" + }, + "profile": { + "title": "プロフィール設定", + "description": "表示名とプロフィール写真を更新します。", + "photoUpdated": "写真を更新しました", + "photoUpdatedMessage": "プロフィール写真が正常に更新されました!", + "uploadFailed": "アップロード失敗", + "uploadFailedMessage": "写真のアップロードに失敗しました。もう一度お試しください。", + "profileUpdated": "プロフィールを更新しました", + "profileUpdatedMessage": "プロフィール情報が正常に保存されました!", + "updateFailed": "更新失敗", + "updateFailedMessage": "プロフィールを更新できませんでした。接続を確認してから再度お試しください。", + "changePhoto": "写真を変更", + "displayName": "表示名", + "displayNamePlaceholder": "表示名を入力してください", + "timezone": "タイムゾーン", + "timezonePlaceholder": "タイムゾーンを選択してください", + "save": "保存", + "cancel": "キャンセル" + }, + "overview": { + "title": "設定", + "subtitle": "体験をカスタマイズし、アカウント設定を管理します", + "upgrade": { + "title": "Plus へアップグレード", + "description": "強力な機能を解放して、生産性を向上させましょう", + "button": "今すぐアップグレード", + "features": { + "richText": "リッチテキストの説明", + "notifications": "タスク通知", + "apiIntegrations": "API 連携", + "advancedAutomation": "高度な自動化" + } + }, + "sections": { + "profile": { + "title": "プロフィール設定", + "description": "プロフィール情報、写真、表示名、タイムゾーンを更新します。" + }, + "circle": { + "title": "サークル設定", + "description": "サークルの管理、メンバーの招待、参加リクエストへの対応を行います。" + }, + "account": { + "title": "アカウント設定", + "description": "サブスクリプションの管理、パスワード変更、アカウント削除を行います。" + }, + "subaccounts": { + "title": "管理アカウント", + "description": "サブアカウントを作成・管理し、ログインや割り当てられたタスクの完了を行えるようにします。" + }, + "notifications": { + "title": "通知", + "description": "プッシュ通知、メール通知、タスクの通知先を設定します。" + }, + "mfa": { + "title": "多要素認証", + "description": "認証アプリを使った MFA でセキュリティをさらに強化します。" + }, + "apitokens": { + "title": "API トークン", + "description": "サードパーティ連携や API アクセス用のトークンを生成・管理します。" + }, + "storage": { + "title": "ストレージ設定", + "description": "データのバックアップと復元、ローカルストレージと同期の設定を管理します。" + }, + "sidepanel": { + "title": "サイドパネルのカスタマイズ", + "description": "サイドパネルのカードのレイアウトと表示をカスタマイズします。" + }, + "theme": { + "title": "テーマ設定", + "description": "好みのテーマを選択し、ダーク/ライトモードの設定を行います。" + }, + "localization": { + "title": "地域設定", + "description": "言語、日付形式、時刻形式、地域の設定をカスタマイズします。" + }, + "advanced": { + "title": "詳細設定", + "description": "Webhook、リアルタイム更新、その他の高度な機能を設定して生産性を高めます。" + }, + "developer": { + "title": "開発者設定", + "description": "認証トークン、SSE 接続、デバッグデータなどの技術情報を確認できます。" + } + } + } +} diff --git a/src/contexts/LocalizationContext.jsx b/src/contexts/LocalizationContext.jsx index 5e4dfb8..f29b571 100644 --- a/src/contexts/LocalizationContext.jsx +++ b/src/contexts/LocalizationContext.jsx @@ -27,6 +27,7 @@ export const AVAILABLE_LANGUAGES = [ { code: 'fr', name: 'French', nativeName: 'Français' }, { code: 'nl', name: 'Dutch', nativeName: 'Nederlands' }, { code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' }, + { code: 'ja', name: 'Japanese', nativeName: '日本語' }, ] export const LocalizationProvider = ({ children }) => { diff --git a/src/i18n/config.js b/src/i18n/config.js index 1b3589f..437d6d8 100644 --- a/src/i18n/config.js +++ b/src/i18n/config.js @@ -1,6 +1,7 @@ import i18n from 'i18next' import LanguageDetector from 'i18next-browser-languagedetector' import HttpBackend from 'i18next-http-backend' +import 'moment/locale/ja' import { initReactI18next } from 'react-i18next' i18n From 6612e7757920354426bc1e693c75a6e743fb8e1b Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Thu, 9 Jul 2026 14:44:05 -0400 Subject: [PATCH 2/9] fix: implement UUID generation utility and replace crypto.randomUUID usage (to support genertaion when we are in http )in task and chore components --- src/utils/UUID.js | 10 + src/views/Authorization/LoginSettings.jsx | 66 ++++- src/views/ChoreEdit/ChoreEdit.jsx | 3 +- src/views/History/ChoreHistory.jsx | 301 ++++++++++++---------- src/views/components/AddTaskModal.jsx | 6 +- 5 files changed, 251 insertions(+), 135 deletions(-) create mode 100644 src/utils/UUID.js diff --git a/src/utils/UUID.js b/src/utils/UUID.js new file mode 100644 index 0000000..49b1d2c --- /dev/null +++ b/src/utils/UUID.js @@ -0,0 +1,10 @@ +export function generateUUID() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID() + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index e8fe85e..914e0e6 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -39,13 +39,14 @@ const LoginSettings = () => { } const testConnection = async url => { + const testURL = url.replace(/\/+$/, '') + '/api/v1/resource' const controller = new AbortController() const timeoutId = setTimeout( () => controller.abort(), CONNECTION_TIMEOUT_MS, ) + try { - const testURL = url.replace(/\/+$/, '') + '/api/v1/resource' const response = await fetch(testURL, { method: 'GET', signal: controller.signal, @@ -55,18 +56,79 @@ const LoginSettings = () => { if (response.status < 500) { return { ok: true } } + if (response.status === 503) { + return { + ok: false, + message: + 'Server is starting up or temporarily unavailable (503). Try again in a moment.', + } + } return { ok: false, message: `Server responded with error ${response.status}. Please check your Donetick server.`, } } catch (err) { clearTimeout(timeoutId) + if (err.name === 'AbortError') { return { ok: false, - message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`, + message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. The host may be unreachable or behind a firewall — check the IP/hostname and network.`, } } + + // Try no-cors to distinguish CORS misconfiguration from server being down + const noCorsController = new AbortController() + const noCorsTimeout = setTimeout(() => noCorsController.abort(), 3000) + try { + const probeStart = Date.now() + const probe = await fetch(testURL, { + method: 'GET', + mode: 'no-cors', + signal: noCorsController.signal, + }) + clearTimeout(noCorsTimeout) + if (probe.type === 'opaque') { + // Server responded but CORS headers blocked the real request + return { + ok: false, + message: + 'Server is reachable but blocked the request (CORS). Ensure your Donetick server allows requests from this origin, or check the server CORS config.', + } + } + // Opaque is the only expected type for no-cors success; anything else is odd + void probeStart + } catch (probeErr) { + clearTimeout(noCorsTimeout) + if (probeErr.name !== 'AbortError') { + // Both normal and no-cors fetch threw immediately → port refused + const msg = probeErr.message?.toLowerCase() ?? '' + if ( + msg.includes('getaddrinfo') || + msg.includes('name not resolved') || + msg.includes('err_name') + ) { + return { + ok: false, + message: + 'Hostname could not be resolved. Check the URL for typos or verify DNS.', + } + } + return { + ok: false, + message: + 'Connection refused. The port may be wrong or nothing is listening — verify the URL and port (default Donetick port is 2021).', + } + } + // no-cors also timed out → server/host truly unreachable + return { + ok: false, + message: + 'Unable to reach the server. Check the URL, port, and network connection.', + } + } + + // Fallback (should rarely hit) return { ok: false, message: diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 7ac6cd4..23f3fee 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -58,6 +58,7 @@ import { UploadChoreAttachment, } from '../../utils/Fetcher' import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers' +import { generateUUID } from '../../utils/UUID' import Priorities from '../../utils/Priorities.jsx' import { getIconComponent } from '../../utils/ProjectIcons' import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' @@ -131,7 +132,7 @@ const ChoreEdit = () => { const [createdBy, setCreatedBy] = useState(0) const [errors, setErrors] = useState({}) const [attemptToSave, setAttemptToSave] = useState(false) - const [draftId] = useState(() => crypto.randomUUID()) + const [draftId] = useState(() => generateUUID()) const [attachments, setAttachments] = useState([]) const [isUploadingAttachment, setIsUploadingAttachment] = useState(false) const [addLabelModalOpen, setAddLabelModalOpen] = useState(false) diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 84ac352..3298408 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -99,12 +99,42 @@ const ChoreHistory = () => { type: 'multi-select', icon: , options: [ - { value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: }, - { value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: }, - { value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: }, - { value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: }, - { value: 5, label: 'Missed', color: 'danger', icon: }, - { value: 6, label: 'Rescheduled', color: 'warning', icon: }, + { + value: ChoreHistoryStatus.COMPLETED, + label: 'Completed', + color: 'success', + icon: , + }, + { + value: ChoreHistoryStatus.SKIPPED, + label: 'Skipped', + color: 'warning', + icon: , + }, + { + value: ChoreHistoryStatus.PENDING_APPROVAL, + label: 'Pending', + color: 'neutral', + icon: , + }, + { + value: ChoreHistoryStatus.REJECTED, + label: 'Rejected', + color: 'danger', + icon: , + }, + { + value: 5, + label: 'Missed', + color: 'danger', + icon: , + }, + { + value: 6, + label: 'Rescheduled', + color: 'warning', + icon: , + }, ], filterFn: (item, values) => values.includes(item.status), }, @@ -143,8 +173,23 @@ const ChoreHistory = () => { [performers], ) - const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } = - useFilter(choreHistory, filterDefs) + const { + filteredData: filteredHistory, + activeFilters, + setFilter, + clearAll, + activeFilterCount, + } = useFilter(choreHistory, filterDefs) + + const sortedHistory = useMemo( + () => + [...filteredHistory].sort( + (a, b) => + new Date(b.performedAt || b.updatedAt) - + new Date(a.performedAt || a.updatedAt), + ), + [filteredHistory], + ) const handleDelete = historyEntry => { showConfirmation( @@ -296,7 +341,7 @@ const ChoreHistory = () => { {/* Enhanced Header Section */} - {/* */} + {/* */} {/* Statistics Cards Grid - Compact Design */} @@ -374,9 +419,8 @@ const ChoreHistory = () => { {/* History Section Header */} - + - { - - - - {filteredHistory.length === 0 && activeFilterCount > 0 && ( + + + + {sortedHistory.length === 0 && activeFilterCount > 0 && ( { )} - {filteredHistory.length > 0 && ( - - {/* Chore History List (Updated Style) */} + {sortedHistory.length > 0 && ( + + {/* Chore History List (Updated Style) */} - - {filteredHistory.map((historyEntry, index) => ( - - - handleEdit(historyEntry)}> - - - - Edit - - - - handleDelete(historyEntry)}> - - - - Delete - - - - - - } - > - { - setDetailModalConfig({ - isOpen: true, - entry: historyEntry, - performers, - onClose: () => setDetailModalConfig({ isOpen: false }), - onEdit: record => { - setDetailModalConfig({ isOpen: false }) - setEditHistory(record) - setIsEditModalOpen(true) - }, - }) - }} - pendingCommands={pendingByHistoryId[historyEntry.id] || []} - onViewNote={notes => { - setNoteViewerConfig({ - isOpen: true, - title: `Updated at ${fmt.dateTime(historyEntry.updatedAt)}`, - content: notes, - onClose: () => setNoteViewerConfig({ isOpen: false }), - }) - }} - onToggleActions={() => { - const id = historyEntry.id || index - if (showMoreInfoId === id) { - setShowMoreInfoId(null) - } else { - setShowMoreInfoId(id) - } - }} - /> - - ))} - - + + {sortedHistory.map((historyEntry, index) => ( + + + handleEdit(historyEntry)}> + + + + Edit + + + + handleDelete(historyEntry)}> + + + + Delete + + + + + + } + > + { + setDetailModalConfig({ + isOpen: true, + entry: historyEntry, + performers, + onClose: () => setDetailModalConfig({ isOpen: false }), + onEdit: record => { + setDetailModalConfig({ isOpen: false }) + setEditHistory(record) + setIsEditModalOpen(true) + }, + }) + }} + pendingCommands={pendingByHistoryId[historyEntry.id] || []} + onViewNote={notes => { + setNoteViewerConfig({ + isOpen: true, + title: `Updated at ${fmt.dateTime(historyEntry.updatedAt)}`, + content: notes, + onClose: () => setNoteViewerConfig({ isOpen: false }), + }) + }} + onToggleActions={() => { + const id = historyEntry.id || index + if (showMoreInfoId === id) { + setShowMoreInfoId(null) + } else { + setShowMoreInfoId(id) + } + }} + /> + + ))} + + )} { - + ) } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index e73c121..fcf6207 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -8,6 +8,7 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useCreateChore } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { isPlusAccount } from '../../utils/Helpers' +import { generateUUID } from '../../utils/UUID' import { useLabels } from '../Labels/LabelQueries' import { useProjects } from '../Projects/ProjectQueries' import { @@ -106,7 +107,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [projectId, setProjectId] = useState(getInitialProject()) const [attachments, setAttachments] = useState([]) - const [draftId, setDraftId] = useState(() => crypto.randomUUID()) + + const [draftId, setDraftId] = useState(() => generateUUID()) const [showScan, setShowScan] = useState(false) const [scanAutoCapture, setScanAutoCapture] = useState(false) const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null) @@ -595,7 +597,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { setDueTime(null) setUseCustomTime(false) setAttachments([]) - setDraftId(crypto.randomUUID()) + setDraftId(generateUUID()) } const createChore = () => { From 1f4646dbdc7a43549d6a6e540a5c869c46efc47f Mon Sep 17 00:00:00 2001 From: cm-moment <50913840+cm-moment@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:17:36 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=AE=80?= =?UTF-8?q?=E4=BD=93=E4=B8=AD=E6=96=87(zh-CN)=E8=AF=AD=E8=A8=80=E6=B1=89?= =?UTF-8?q?=E5=8C=96=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 zh-CN 简体中文国际化目录 2. 完成 chores、common、settings 页面全部文本汉化 3. 语言编码遵循 BCP47 标准,与现有多语言目录结构统一 --- public/locales/zh-CN/chores.json | 75 +++++++++++++ public/locales/zh-CN/common.json | 33 ++++++ public/locales/zh-CN/settings.json | 174 +++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 public/locales/zh-CN/chores.json create mode 100644 public/locales/zh-CN/common.json create mode 100644 public/locales/zh-CN/settings.json diff --git a/public/locales/zh-CN/chores.json b/public/locales/zh-CN/chores.json new file mode 100644 index 0000000..3f966f1 --- /dev/null +++ b/public/locales/zh-CN/chores.json @@ -0,0 +1,75 @@ +{ + "title": "家务", + "myChores": "我的家务", + "allChores": "所有家务", + "addChore": "添加家务", + "editChore": "编辑家务", + "deleteChore": "删除家务", + "completeChore": "完成任务", + "dueDate": "截止日期", + "assignedTo": "分配给", + "priority": "优先级", + "status": "状态", + "description": "描述", + "choreView": { + "assignment": "分配", + "assigned": "已分配", + "last": "上次", + "schedule": "日程", + "due": "截止", + "statistics": "统计", + "completed": "已完成", + "times": "次", + "details": "详情", + "createdBy": "创建者", + "na": "无", + "taskCompleted": "任务已完成", + "taskCompletedMessage": "您的任务已标记为完成", + "taskCompletionUndone": "任务完成已撤销。", + "taskSkipUndone": "任务跳过已撤销。", + "undoSuccessful": "撤销成功", + "undoFailed": "撤销失败", + "undoFailedMessage": "无法撤销该操作。请重试。", + "resetTimer": "重置计时器", + "resetTimerConfirmation": "您确定要重置计时器吗?这将清除自您开始任务以来的所有时间记录。", + "clearAllTimeRecords": "清除所有时间记录", + "clearAllTimeConfirmation": "这将永久删除此任务的所有计时器,并将其重置为“未开始”。", + "descriptionTitle": "描述", + "description": "描述:", + "previousNote": "上一条备注", + "previousNoteLabel": "上一条备注:", + "subtasksLabel": "子任务:", + "taskActions": "任务操作", + "addNote": "添加备注", + "additionalNotes": "附加备注:", + "notePlaceholder": "添加关于完成的备注...", + "setCustomCompletionTime": "设置自定义完成时间", + "skipTask": "跳过任务", + "skipTaskConfirmation": "您确定要跳过此任务吗?", + "markComplete": "标记完成", + "markAsDone": "标记为已完成", + "edit": "编辑", + "archive": "归档", + "unarchive": "取消归档", + "viewHistory": "查看历史", + "history": "历史", + "startTimer": "开始计时", + "start": "开始", + "pauseTimer": "暂停计时", + "approve": "批准", + "reject": "拒绝", + "pendingApproval": "待批准", + "undo": "撤销", + "skip": "跳过", + "cancel": "取消", + "noPriority": "无优先级", + "subtasks": "子任务", + "noDescription": "暂无描述", + "timer": { + "active": "计时中", + "paused": "已暂停", + "reset": "重置计时器", + "delete": "删除会话" + } + } +} \ No newline at end of file diff --git a/public/locales/zh-CN/common.json b/public/locales/zh-CN/common.json new file mode 100644 index 0000000..47bf53b --- /dev/null +++ b/public/locales/zh-CN/common.json @@ -0,0 +1,33 @@ +{ + "save": "保存", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "close": "关闭", + "confirm": "确认", + "loading": "加载中...", + "error": "错误", + "success": "成功", + "warning": "警告", + "refresh": "刷新", + "copy": "复制", + "copied": "已复制!", + "settings": "设置", + "yes": "是", + "no": "否", + "back": "返回", + "backToCalendar": "返回日历", + "logout": "退出登录", + "version": "版本", + "navigation": { + "allTasks": "所有任务", + "archived": "已归档", + "things": "事项", + "labels": "标签", + "projects": "项目", + "filters": "筛选", + "activities": "活动", + "points": "积分", + "settings": "设置" + } +} \ No newline at end of file diff --git a/public/locales/zh-CN/settings.json b/public/locales/zh-CN/settings.json new file mode 100644 index 0000000..9294aca --- /dev/null +++ b/public/locales/zh-CN/settings.json @@ -0,0 +1,174 @@ +{ + "title": "设置", + "circleSettings": { + "title": "圈子设置", + "description": "当您创建或加入圈子时,您的账户会自动连接到该圈子。通过分享下方的唯一圈子代码或链接,即可轻松邀请好友。当有人请求加入您的圈子时,您会在下方收到通知。如果您想离开,只需点击“离开圈子”按钮。", + "circleCode": "圈子代码", + "copyCode": "复制代码", + "copyLink": "复制链接", + "codeCopied": "圈子代码已复制!", + "linkCopied": "圈子链接已复制!", + "joinCircle": "加入圈子", + "joinCirclePlaceholder": "输入圈子代码", + "join": "加入", + "leave": "离开圈子", + "leaveConfirmTitle": "离开圈子", + "leaveConfirmMessage": "您确定要离开此圈子吗?", + "circleMembers": "圈子成员", + "circleMemberRequests": "圈子成员申请", + "admin": "管理员", + "member": "成员", + "pending": "待处理", + "accept": "接受", + "reject": "拒绝", + "makeAdmin": "设为管理员", + "makeMember": "设为成员", + "remove": "移除", + "webhookURL": "Webhook URL", + "webhookDescription": "输入 Webhook URL 以接收圈子事件通知", + "webhookPlaceholder": "https://your-webhook-url.com" + }, + "accountSettings": { + "title": "账户设置", + "subscription": "订阅", + "subscriptionStatus": "当前方案", + "free": "免费版", + "plus": "Plus 版", + "upgrade": "升级", + "cancel": "取消", + "changePassword": "修改密码", + "password": "密码", + "dangerZone": "危险区域", + "dangerZoneDescription": "一旦删除账户,将无法恢复。请谨慎操作。", + "deleteAccount": "删除账户" + }, + "localization": { + "title": "本地化", + "description": "自定义您账户的语言、日期格式和区域偏好。", + "language": "语言", + "languageDescription": "选择您的首选语言", + "dateFormat": "日期格式", + "dateFormatDescription": "选择整个应用中日期的显示方式", + "timeFormat": "时间格式", + "timeFormatDescription": "选择 12 小时制或 24 小时制", + "12hour": "12 小时制 (AM/PM)", + "24hour": "24 小时制", + "firstDayOfWeek": "每周起始日", + "firstDayOfWeekDescription": "选择每周的第一天", + "sunday": "星期日", + "monday": "星期一", + "saturday": "星期六", + "formats": { + "mdy": "MM/DD/YYYY (美国)", + "dmy": "DD/MM/YYYY (欧洲)", + "ymd": "YYYY-MM-DD (ISO)", + "long": "长格式 (例如:2024年1月1日)", + "short": "短格式 (例如:2024年1月1日)" + } + }, + "sidepanel": { + "title": "侧边栏自定义", + "description": "自定义侧边栏中卡片的布局和可见性。此部分仅适用于平板电脑和台式机等大屏设备。" + }, + "theme": { + "title": "主题偏好", + "description": "选择网站的显示外观。您可以选择单一主题,或与系统同步,自动切换日间和夜间主题。", + "themeMode": "主题模式", + "light": "浅色", + "dark": "深色", + "system": "跟随系统" + }, + "notifications": { + "settingsSaved": "设置保存成功", + "settingsSaveFailed": "设置保存失败", + "invalidWebhook": "无效的 Webhook URL" + }, + "profile": { + "title": "个人资料设置", + "description": "更新您的显示名称和个人头像。", + "photoUpdated": "头像已更新", + "photoUpdatedMessage": "您的个人头像已成功更新!", + "uploadFailed": "上传失败", + "uploadFailedMessage": "头像上传失败。请重试。", + "profileUpdated": "资料已更新", + "profileUpdatedMessage": "您的个人资料已成功保存!", + "updateFailed": "更新失败", + "updateFailedMessage": "无法更新您的个人资料。请检查网络连接后重试。", + "changePhoto": "更换头像", + "displayName": "显示名称", + "displayNamePlaceholder": "输入您的显示名称", + "timezone": "时区", + "timezonePlaceholder": "选择您的时区", + "save": "保存", + "cancel": "取消" + }, + "overview": { + "title": "设置", + "subtitle": "自定义您的体验并管理账户偏好", + "upgrade": { + "title": "升级到 Plus", + "description": "解锁强大功能,提升您的效率", + "button": "立即升级", + "features": { + "richText": "富文本描述", + "notifications": "任务通知", + "apiIntegrations": "API 集成", + "advancedAutomation": "高级自动化" + } + }, + "sections": { + "profile": { + "title": "个人资料设置", + "description": "更新您的个人资料信息、头像、显示名称和时区偏好。" + }, + "circle": { + "title": "圈子设置", + "description": "管理您的圈子、邀请成员并处理加入申请。" + }, + "account": { + "title": "账户设置", + "description": "管理订阅、修改密码及账户删除选项。" + }, + "subaccounts": { + "title": "托管账户", + "description": "创建和管理子账户,以便登录并完成分配的任务。" + }, + "notifications": { + "title": "通知", + "description": "配置推送通知、邮件提醒及任务通知目标。" + }, + "mfa": { + "title": "多因素认证", + "description": "使用身份验证器应用启用 MFA,增加一层安全保障。" + }, + "apitokens": { + "title": "API 令牌", + "description": "生成和管理用于第三方集成及 API 访问的访问令牌。" + }, + "storage": { + "title": "存储设置", + "description": "备份和恢复数据,管理本地存储及同步偏好。" + }, + "sidepanel": { + "title": "侧边栏自定义", + "description": "自定义侧边栏界面中卡片的布局和可见性。" + }, + "theme": { + "title": "主题偏好", + "description": "选择您喜欢的主题并配置深色/浅色模式设置。" + }, + "localization": { + "title": "本地化", + "description": "自定义语言、日期格式、时间格式及区域偏好。" + }, + "advanced": { + "title": "高级设置", + "description": "配置 Webhook、实时更新及其他高级功能以提升效率。" + }, + "developer": { + "title": "开发者设置", + "description": "查看有关身份验证令牌、SSE 连接及调试数据的技术信息。" + } + } + } +} \ No newline at end of file From b1ff6805ebafc15258a1ba77f0fcd2ecb78f1de4 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 10 Jul 2026 01:07:55 -0400 Subject: [PATCH 4/9] fix: update NFC URL generation to support iOS platform in ChoreModals component --- src/views/Chores/components/ChoreModals.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/views/Chores/components/ChoreModals.jsx b/src/views/Chores/components/ChoreModals.jsx index 9191088..4f31ad0 100644 --- a/src/views/Chores/components/ChoreModals.jsx +++ b/src/views/Chores/components/ChoreModals.jsx @@ -6,7 +6,7 @@ import TextModal from '../../Modals/Inputs/TextModal' import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal' const getNFCUrl = choreId => - Capacitor.getPlatform() === 'android' + Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios' ? `donetick://chores/${choreId}` : `${window.location.origin}/chores/${choreId}` @@ -77,7 +77,6 @@ const ChoreModals = ({ /> )} - {activeModal === 'nudge' && modalChore && ( Date: Fri, 10 Jul 2026 01:45:16 -0400 Subject: [PATCH 5/9] fix: correct file patterns in Crowdin configuration for locale files --- crowdin.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crowdin.yml b/crowdin.yml index 677bd2c..eae070f 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -6,6 +6,6 @@ api_token_env: CROWDIN_API_TOKEN preserve_hierarchy: true files: - - source: /public/locales/en/**/*.json - translation: /public/locales/%two_letters_code%/**/%original_file_name% + - source: /public/locales/en/*.json + translation: /public/locales/%two_letters_code%/%original_file_name% update_option: update_as_unapproved From b7cc19b719cd8bae77260f525edf80678d69356a Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 12 Jul 2026 18:11:00 -0400 Subject: [PATCH 6/9] format --- src/views/components/AddTaskModal.jsx | 622 +++++++++++++------------- 1 file changed, 321 insertions(+), 301 deletions(-) diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index fcf6207..8f5a08d 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -22,6 +22,8 @@ import { import SmartTaskTitleInput from './SmartTaskTitleInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' +import { useDocumentScanner } from '../../hooks/useDocumentScanner' +import { localAIService } from '../../service/LocalAIService' import { TASK_COLOR } from '../../utils/Colors' import AssigneePickerField from './AssigneePickerField' import AttachmentPickerField from './AttachmentPickerField' @@ -29,12 +31,10 @@ import DueDatePickerField from './DueDatePickerField' import LabelsPickerField from './LabelsPickerField' import LearnMoreButton from './LearnMore' import NotificationPickerField from './NotificationPickerField' -import ScanPanel from './ScanToTask/ScanPanel' -import { useDocumentScanner } from '../../hooks/useDocumentScanner' -import { localAIService } from '../../service/LocalAIService' import PriorityPickerField from './PriorityPickerField' import RepeatPickerField from './RepeatPickerField' import RichTextEditor from './RichTextEditor' +import ScanPanel from './ScanToTask/ScanPanel' import SubTasks from './SubTask' const getDefaultNotification = () => { const storedDefault = localStorage.getItem('defaultNotificationTemplate') @@ -558,7 +558,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { createChore() } - const handleTaskExtracted = ({ taskName, description: extractedDesc, dueDate: extractedDue }) => { + const handleTaskExtracted = ({ + taskName, + description: extractedDesc, + dueDate: extractedDue, + }) => { if (taskName) { processText(taskName) } @@ -700,314 +704,330 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { return ( <> - - - - - } - > - {!showScan && ( - <> - - - Task in a sentence: - - - This feature lets you create a task simply by typing a - sentence. It attempt parses the sentence to identify the - task's due date, priority, and frequency. - - - - Examples: - - - -
  • - Priority:For highest priority any of the - following keyword P1, Urgent,{' '} - Important, or ASAP. For lower - priorities, use P2, P3, or P4. -
  • -
  • - Due date: Specify dates with phrases - like tomorrow, next week,{' '} - Monday, or August 1st at 12pm. -
  • -
  • - Frequency: Set recurring tasks with - terms like daily, weekly,{' '} - monthly, yearly, or patterns such as{' '} - every Tuesday and Thursday. -
  • -
    - - } - /> -
    - - { - setScanAutoCapture(true) - setShowScan(true) - } : undefined} - onPhotoSelected={llmAvailable ? dataUrl => { - setScanAutoCapture(false) - setPendingPhotoUrl(dataUrl) - setShowScan(true) - } : undefined} - placeholder='Type your task...' - onChange={text => { - setTaskText(text) - if (!text) setTaskTitle('') - }} - customRenderer={renderedParts} - onEnterPressed={handleEnterPressed} - suggestions={{ - '#': { - value: 'id', - display: 'name', - options: userLabels ? userLabels : [], - }, - '!': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: 'P1' }, - { id: '2', name: 'P2' }, - { id: '3', name: 'P3' }, - { id: '4', name: 'P4' }, - ], - }, - '@': { - value: 'userId', - display: 'displayName', - options: [ - { userId: 'anyone', displayName: 'Anyone' }, - ...(circleMembers?.res || []), - ], - }, - '*': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: '1 point' }, - { id: '5', name: '5 points' }, - { id: '10', name: '10 points' }, - { id: '25', name: '25 points' }, - { id: '50', name: '50 points' }, - { id: '100', name: '100 points' }, - ], - }, - }} - /> -
    - + - { - setDueDate(null) - setDueDateOnly(null) - setDueTime(null) - setUseCustomTime(false) - }} - /> - setFrequency(null)} - /> - setPriority(0)} - emptyDisplay={pickerEmptyDisplay} - priorityColors={priorityColors} - priorityLabels={priorityLabels} - /> - { - if (!userId) { - setAssignees([]) - } else { - setAssignees([{ userId }]) - } - }} - onClear={() => setAssignees([])} - currentUserId={userProfile?.id} - members={circleMembers?.res || []} - /> - setLabelsV2([])} - labels={userLabels || []} - /> - setAttachments([])} - emptyDisplay={pickerEmptyDisplay} - entityType='chore_attachment_draft' - draftId={draftId} - /> - setNotificationMetadata({ templates: [] })} - emptyDisplay={pickerEmptyDisplay} - /> - - - - {!hasDescription && ( - - )} - {!hasSubTasks && ( - - )} - - - {hasDescription && ( - - Description: -
    - + Cancel + {showKeyboardShortcuts && ( + -
    -
    - )} - {hasSubTasks && ( + )} + + + + } + > + {!showScan && ( + <> - Subtasks: - + Task in a sentence: + + + This feature lets you create a task simply by typing a + sentence. It attempt parses the sentence to identify the + task's due date, priority, and frequency. + + + + Examples: + + + +
  • + Priority:For highest priority any of + the following keyword P1, Urgent,{' '} + Important, or ASAP. For lower + priorities, use P2, P3, or{' '} + P4. +
  • +
  • + Due date: Specify dates with phrases + like tomorrow, next week,{' '} + Monday, or August 1st at 12pm. +
  • +
  • + Frequency: Set recurring tasks with + terms like daily, weekly,{' '} + monthly, yearly, or patterns such as{' '} + every Tuesday and Thursday. +
  • +
    + + } + /> +
    + + { + setScanAutoCapture(true) + setShowScan(true) + } + : undefined + } + onPhotoSelected={ + llmAvailable + ? dataUrl => { + setScanAutoCapture(false) + setPendingPhotoUrl(dataUrl) + setShowScan(true) + } + : undefined + } + placeholder='Type your task...' + onChange={text => { + setTaskText(text) + if (!text) setTaskTitle('') + }} + customRenderer={renderedParts} + onEnterPressed={handleEnterPressed} + suggestions={{ + '#': { + value: 'id', + display: 'name', + options: userLabels ? userLabels : [], + }, + '!': { + value: 'id', + display: 'name', + options: [ + { id: '1', name: 'P1' }, + { id: '2', name: 'P2' }, + { id: '3', name: 'P3' }, + { id: '4', name: 'P4' }, + ], + }, + '@': { + value: 'userId', + display: 'displayName', + options: [ + { userId: 'anyone', displayName: 'Anyone' }, + ...(circleMembers?.res || []), + ], + }, + '*': { + value: 'id', + display: 'name', + options: [ + { id: '1', name: '1 point' }, + { id: '5', name: '5 points' }, + { id: '10', name: '10 points' }, + { id: '25', name: '25 points' }, + { id: '50', name: '50 points' }, + { id: '100', name: '100 points' }, + ], + }, + }} /> - )} - - )} - {showScan && ( - { - setShowScan(false) - setScanAutoCapture(false) - setPendingPhotoUrl(null) - }} - /> - )} -
    + + { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + }} + /> + setFrequency(null)} + /> + setPriority(0)} + emptyDisplay={pickerEmptyDisplay} + priorityColors={priorityColors} + priorityLabels={priorityLabels} + /> + { + if (!userId) { + setAssignees([]) + } else { + setAssignees([{ userId }]) + } + }} + onClear={() => setAssignees([])} + currentUserId={userProfile?.id} + members={circleMembers?.res || []} + /> + setLabelsV2([])} + labels={userLabels || []} + /> + setAttachments([])} + emptyDisplay={pickerEmptyDisplay} + entityType='chore_attachment_draft' + draftId={draftId} + /> + setNotificationMetadata({ templates: [] })} + emptyDisplay={pickerEmptyDisplay} + /> + + + + {!hasDescription && ( + + )} + {!hasSubTasks && ( + + )} + + + {hasDescription && ( + + Description: +
    + +
    +
    + )} + {hasSubTasks && ( + + Subtasks: + + + )} + + )} + + {showScan && ( + { + setShowScan(false) + setScanAutoCapture(false) + setPendingPhotoUrl(null) + }} + /> + )} +
    ) } From 5332d0ac62e87be6ea200138b9c34ec5c1434d3f Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 12 Jul 2026 23:34:19 -0400 Subject: [PATCH 7/9] streamline task management in SubTask component. have better shortcut and keyboard movement --- src/views/components/SubTask.jsx | 831 +++++++++++++++++++------------ 1 file changed, 501 insertions(+), 330 deletions(-) diff --git a/src/views/components/SubTask.jsx b/src/views/components/SubTask.jsx index 01a505f..673ca74 100644 --- a/src/views/components/SubTask.jsx +++ b/src/views/components/SubTask.jsx @@ -7,7 +7,6 @@ import { } from '@dnd-kit/core' import { SortableContext, - arrayMove, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' @@ -16,10 +15,8 @@ import { ChevronRight, Delete, DragIndicator, - Edit, ExpandMore, KeyboardReturn, - PlaylistAdd, } from '@mui/icons-material' import { Box, @@ -31,47 +28,56 @@ import { ListItem, Typography, } from '@mui/joy' -import { useState } from 'react' +import { useCallback, useRef, useState } from 'react' +import { flushSync } from 'react-dom' import { useLocalization } from '../../contexts/LocalizationContext' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext' import { useUserProfile } from '../../queries/UserQueries' import { CompleteSubTask } from '../../utils/Fetcher' +function getVisibleOrder(tasks, expandedIds) { + const result = [] + const addTask = task => { + result.push(task) + if (expandedIds.has(task.id)) { + tasks + .filter(t => t.parentId === task.id) + .sort((a, b) => a.orderId - b.orderId) + .forEach(addTask) + } + } + tasks + .filter(t => t.parentId === null) + .sort((a, b) => a.orderId - b.orderId) + .forEach(addTask) + return result +} + +function nextTempId(tasks) { + return Math.min(0, ...tasks.map(t => t.id)) - 1 +} + function SortableItem({ task, - index, - handleToggle, - handleDelete, - handleAddSubtask, allTasks, setTasks, - level = 0, + level, editMode, - performers = [], + expandedIds, + onToggleExpand, + handleToggle, + inputRefs, + onKeyDown, + performers, }) { const { fmt } = useLocalization() const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ - id: task.id, - data: { completedAt: task.completedAt, completedBy: task.completedBy }, - // Add touch sensor options for better mobile scrolling - options: { - activationConstraint: { - // Require a small movement before activating drag to allow scrolling - delay: 250, - tolerance: 5, - }, - }, - }) + useSortable({ id: task.id }) - const [isEditing, setIsEditing] = useState(false) - const [editedText, setEditedText] = useState(task.name) - const [expanded, setExpanded] = useState(false) - const [showAddSubtask, setShowAddSubtask] = useState(false) - const [newSubtask, setNewSubtask] = useState('') - - // Find child tasks - const childTasks = allTasks.filter(t => t.parentId === task.id) + const expanded = expandedIds.has(task.id) + const childTasks = allTasks + .filter(t => t.parentId === task.id) + .sort((a, b) => a.orderId - b.orderId) const hasChildren = childTasks.length > 0 const style = { @@ -79,226 +85,154 @@ function SortableItem({ transition, display: 'flex', alignItems: 'center', - gap: '0.5rem', - flexDirection: { xs: 'column', sm: 'row' }, - // Enable default touch behavior for scrolling touchAction: 'auto', paddingLeft: `${level * 24}px`, } - const handleEdit = () => { - setIsEditing(true) - } - - const handleSave = () => { - setIsEditing(false) - task.name = editedText - // Update the task in the parent component - setTasks(prevTasks => - prevTasks.map(t => (t.id === task.id ? { ...t, name: editedText } : t)), - ) - } - - const handleExpandClick = () => { - setExpanded(!expanded) - } - - const handleAddSubtaskClick = () => { - setShowAddSubtask(!showAddSubtask) - } - - const submitNewSubtask = () => { - if (!newSubtask.trim()) return - - handleAddSubtask(task.id, newSubtask) - setNewSubtask('') - setShowAddSubtask(false) - setExpanded(true) // Auto-expand to show the new subtask - } - - const handleKeyPress = event => { - if (event.key === 'Enter') { - submitNewSubtask() - } - } - return ( <> {editMode && ( )} - {hasChildren && ( + {hasChildren ? ( onToggleExpand(task.id)} > {expanded ? : } - )} + ) : level > 0 ? ( + + ) : null} - {!hasChildren && level > 0 && ( - // Spacer for alignment - )} - - + {!editMode && ( handleToggle(task.id)} /> )} - { - if (!editMode) { - handleToggle(task.id) + + {editMode ? ( + { + inputRefs.current[task.id] = el + }, + }, + }} + value={task.name} + placeholder='Task name...' + onChange={e => + setTasks(prev => + prev.map(t => + t.id === task.id ? { ...t, name: e.target.value } : t, + ), + ) } - }} - > - {isEditing ? ( - setEditedText(e.target.value)} - onBlur={handleSave} - onKeyDown={e => { - if (!(e.metaKey || e.ctrlKey) && e.key === 'Enter') { - handleSave() - } - }} - autoFocus - /> - ) : ( + onKeyDown={e => onKeyDown(e, task)} + sx={{ + flex: 1, + border: 'none', + backgroundColor: 'transparent', + boxShadow: 'none', + '--Input-focusedHighlight': 'var(--joy-palette-primary-300)', + '&:not(:focus-within)': { + boxShadow: 'none', + backgroundColor: 'transparent', + }, + }} + /> + ) : ( + handleToggle(task.id)} + > {task.name} - )} - {task.completedAt && ( - - {fmt.dateTime(task.completedAt)} - {performers.find(p => p.userId === task.completedBy) ? ( - - { - performers.find(p => p.userId === task.completedBy) - .displayName - } - - ) : null} - - )} - - - - - {editMode && ( - <> - - - - - - - handleDelete(task.id)} - > - - - + {task.completedAt && ( + + {fmt.dateTime(task.completedAt)} + {performers?.find(p => p.userId === task.completedBy) && ( + + { + performers.find(p => p.userId === task.completedBy) + .displayName + } + + )} + + )} + )} - - {/* Add subtask input field */} - {showAddSubtask && ( - - - setNewSubtask(e.target.value)} - onKeyPress={handleKeyPress} - sx={{ flex: 1 }} - autoFocus - /> - - + {editMode && ( + + + onKeyDown( + { + key: 'Backspace', + shiftKey: true, + preventDefault: () => {}, + }, + task, + ) + } + > + - - )} + )} + - {/* Child tasks */} {hasChildren && expanded && ( - - {childTasks - .sort((a, b) => a.orderId - b.orderId) - .map((childTask, childIndex) => ( - - ))} + + {childTasks.map(childTask => ( + + ))} )} @@ -314,19 +248,22 @@ const SubTasks = ({ shouldFocus = false, }) => { const [newTask, setNewTask] = useState('') + const [expandedIds, setExpandedIds] = useState(new Set()) const { data: userProfile } = useUserProfile() const { impersonatedUser } = useImpersonateUser() + const inputRefs = useRef({}) + + const focusId = id => { + setTimeout(() => { + inputRefs.current[id]?.focus() + }, 50) + } const topLevelTasks = tasks.filter(task => task.parentId === null) - // Create sensors for touch handling const sensors = useSensors( useSensor(PointerSensor, { - // Configure for better mobile scrolling - activationConstraint: { - delay: 100, - tolerance: 8, - }, + activationConstraint: { delay: 100, tolerance: 8 }, }), ) @@ -336,7 +273,6 @@ const SubTasks = ({ ? null : new Date().toISOString() - // Update the task const updatedTasks = tasks.map(task => task.id === taskId ? { @@ -347,7 +283,6 @@ const SubTasks = ({ : task, ) - // If completing a task, also complete all child tasks if (newCompletedAt) { const completeChildren = parentId => { const children = updatedTasks.filter(t => t.parentId === parentId) @@ -358,7 +293,7 @@ const SubTasks = ({ ...updatedTasks[index], completedAt: newCompletedAt, } - completeChildren(child.id) // Recursively complete grandchildren + completeChildren(child.id) } }) } @@ -366,73 +301,309 @@ const SubTasks = ({ } CompleteSubTask(taskId, Number(choreId), newCompletedAt).then(res => { - if (res.status !== 200) { - console.log('Error updating task') - return - } + if (res.status !== 200) console.log('Error updating task') }) setTasks(updatedTasks) } - const handleDelete = taskId => { - // Find all descendant tasks to delete - const findDescendants = id => { - const descendants = [] - const children = tasks.filter(t => t.parentId === id) + const handleDelete = useCallback( + taskId => { + const findDescendants = id => { + const descendants = [] + tasks + .filter(t => t.parentId === id) + .forEach(child => { + descendants.push(child.id) + descendants.push(...findDescendants(child.id)) + }) + return descendants + } + const idsToDelete = [taskId, ...findDescendants(taskId)] + setTasks( + tasks + .filter(task => !idsToDelete.includes(task.id)) + .map((task, index) => ({ + ...task, + orderId: task.parentId === null ? index : task.orderId, + })), + ) + }, + [tasks, setTasks], + ) - children.forEach(child => { - descendants.push(child.id) - descendants.push(...findDescendants(child.id)) - }) + const handleToggleExpand = useCallback(taskId => { + setExpandedIds(prev => { + const next = new Set(prev) + next.has(taskId) ? next.delete(taskId) : next.add(taskId) + return next + }) + }, []) - return descendants - } + const handleKeyDown = useCallback( + (e, task) => { + const input = inputRefs.current[task.id] + const selStart = input?.selectionStart ?? 0 + const selEnd = input?.selectionEnd ?? 0 + const valLen = input?.value?.length ?? 0 + const cursorAtStart = selStart === 0 && selEnd === 0 + const cursorAtEnd = selStart === valLen && selEnd === valLen - const descendantIds = findDescendants(taskId) - const idsToDelete = [taskId, ...descendantIds] + // Enter → add sibling after current task at same level + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + const newId = nextTempId(tasks) + const newTaskObj = { + id: newId, + name: '', + completedAt: null, + parentId: task.parentId, + orderId: task.orderId + 1, + } + setTasks(prev => [ + ...prev.map(t => + t.parentId === task.parentId && t.orderId > task.orderId + ? { ...t, orderId: t.orderId + 1 } + : t, + ), + newTaskObj, + ]) + focusId(newId) + return + } - // Filter out the task and all its descendants - const updatedTasks = tasks - .filter(task => !idsToDelete.includes(task.id)) - .map((task, index) => ({ - ...task, - orderId: task.parentId === null ? index : task.orderId, - })) + // Shift+Enter → add child subtask nested under current + if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault() + const newId = nextTempId(tasks) + const childCount = tasks.filter(t => t.parentId === task.id).length + const newTaskObj = { + id: newId, + name: '', + completedAt: null, + parentId: task.id, + orderId: childCount, + } + setExpandedIds(prev => new Set([...prev, task.id])) + setTasks(prev => [...prev, newTaskObj]) + focusId(newId) + return + } - setTasks(updatedTasks) - } + // ArrowUp → focus previous visible task + if (e.key === 'ArrowUp' && !e.shiftKey) { + e.preventDefault() + const visible = getVisibleOrder(tasks, expandedIds) + const idx = visible.findIndex(t => t.id === task.id) + if (idx > 0) inputRefs.current[visible[idx - 1].id]?.focus() + return + } + + // ArrowDown → focus next visible task + if (e.key === 'ArrowDown' && !e.shiftKey) { + e.preventDefault() + const visible = getVisibleOrder(tasks, expandedIds) + const idx = visible.findIndex(t => t.id === task.id) + if (idx < visible.length - 1) + inputRefs.current[visible[idx + 1].id]?.focus() + return + } + + // Shift+ArrowUp → move task up among siblings; at top, promote before parent + if (e.key === 'ArrowUp' && e.shiftKey) { + e.preventDefault() + const siblings = tasks + .filter(t => t.parentId === task.parentId) + .sort((a, b) => a.orderId - b.orderId) + const idx = siblings.findIndex(t => t.id === task.id) + if (idx <= 0) { + // Already first sibling — promote to parent level, insert before parent + if (task.parentId === null) return + const parent = tasks.find(t => t.id === task.parentId) + if (!parent) return + setTasks(prev => + prev.map(t => { + // Shift items at parent's orderId and above to make room + if (t.id === task.id) + return { + ...t, + parentId: parent.parentId, + orderId: parent.orderId, + } + if ( + t.parentId === parent.parentId && + t.orderId >= parent.orderId && + t.id !== task.id + ) + return { ...t, orderId: t.orderId + 1 } + return t + }), + ) + focusId(task.id) + return + } + const prev = siblings[idx - 1] + setTasks(all => + all.map(t => { + if (t.id === task.id) return { ...t, orderId: prev.orderId } + if (t.id === prev.id) return { ...t, orderId: task.orderId } + return t + }), + ) + focusId(task.id) + return + } + + // Shift+ArrowDown → move task down among siblings; at bottom, promote after parent + if (e.key === 'ArrowDown' && e.shiftKey) { + e.preventDefault() + const siblings = tasks + .filter(t => t.parentId === task.parentId) + .sort((a, b) => a.orderId - b.orderId) + const idx = siblings.findIndex(t => t.id === task.id) + if (idx >= siblings.length - 1) { + // Already last sibling — promote to parent level, insert after parent + if (task.parentId === null) return + const parent = tasks.find(t => t.id === task.parentId) + if (!parent) return + setTasks(prev => + prev.map(t => { + if (t.id === task.id) + return { + ...t, + parentId: parent.parentId, + orderId: parent.orderId + 1, + } + if ( + t.parentId === parent.parentId && + t.orderId > parent.orderId && + t.id !== task.id + ) + return { ...t, orderId: t.orderId + 1 } + return t + }), + ) + focusId(task.id) + return + } + const next = siblings[idx + 1] + setTasks(all => + all.map(t => { + if (t.id === task.id) return { ...t, orderId: next.orderId } + if (t.id === next.id) return { ...t, orderId: task.orderId } + return t + }), + ) + focusId(task.id) + return + } + + // Shift+ArrowLeft (at cursor start) or Shift+Tab → outdent one level + const shouldOutdent = + (e.key === 'ArrowLeft' && e.shiftKey && cursorAtStart) || + (e.key === 'Tab' && e.shiftKey) + + if (shouldOutdent) { + e.preventDefault() + if (task.parentId === null) return + const parent = tasks.find(t => t.id === task.parentId) + if (!parent) return + const newOrderId = parent.orderId + 1 + setTasks(prev => + prev.map(t => { + if (t.id === task.id) + return { ...t, parentId: parent.parentId, orderId: newOrderId } + if ( + t.parentId === parent.parentId && + t.orderId >= newOrderId && + t.id !== task.id + ) + return { ...t, orderId: t.orderId + 1 } + return t + }), + ) + focusId(task.id) + return + } + + // Shift+ArrowRight (at cursor end) or Tab → indent under previous sibling + const shouldIndent = + (e.key === 'ArrowRight' && e.shiftKey && cursorAtEnd) || + (e.key === 'Tab' && !e.shiftKey) + + if (shouldIndent) { + e.preventDefault() + const siblings = tasks + .filter(t => t.parentId === task.parentId) + .sort((a, b) => a.orderId - b.orderId) + const idx = siblings.findIndex(t => t.id === task.id) + if (idx <= 0) return + const newParent = siblings[idx - 1] + const newChildCount = tasks.filter( + t => t.parentId === newParent.id, + ).length + setExpandedIds(prev => new Set([...prev, newParent.id])) + setTasks(prev => + prev.map(t => + t.id === task.id + ? { ...t, parentId: newParent.id, orderId: newChildCount } + : t, + ), + ) + focusId(task.id) + return + } + + // Backspace on empty task → delete and focus previous + if (e.key === 'Backspace' && !e.shiftKey && task.name === '') { + e.preventDefault() + const visible = getVisibleOrder(tasks, expandedIds) + const idx = visible.findIndex(t => t.id === task.id) + if (idx > 0) focusId(visible[idx - 1].id) + handleDelete(task.id) + return + } + + // Shift+Backspace or Shift+Delete → delete task and focus nearest + if ((e.key === 'Backspace' || e.key === 'Delete') && e.shiftKey) { + e.preventDefault() + const visible = getVisibleOrder(tasks, expandedIds) + const idx = visible.findIndex(t => t.id === task.id) + if (idx > 0) focusId(visible[idx - 1].id) + else if (idx < visible.length - 1) focusId(visible[idx + 1].id) + handleDelete(task.id) + return + } + + // Escape → blur current input + if (e.key === 'Escape') { + input?.blur() + } + }, + [tasks, expandedIds, setTasks, handleDelete], + ) + + const addInputRef = useRef(null) const handleAdd = () => { if (!newTask.trim()) return - - const newTaskObj = { - name: newTask, - completedAt: null, - orderId: topLevelTasks.length, - parentId: null, - id: (tasks.length + 1) * -1, // Temporary negative ID - } - - setTasks([...tasks, newTaskObj]) - setNewTask('') - } - - const handleAddSubtask = (parentId, name) => { - if (!name.trim()) return - - // Find siblings to determine orderId - const siblings = tasks.filter(t => t.parentId === parentId) - - const newSubtask = { - name, - completedAt: null, - orderId: siblings.length, - parentId, - id: (tasks.length + 1) * -1, // Temporary negative ID - } - - setTasks([...tasks, newSubtask]) + const id1 = nextTempId(tasks) + const id2 = id1 - 1 + flushSync(() => { + setTasks([ + ...tasks, + { + id: id1, + name: newTask, + completedAt: null, + orderId: 0, + parentId: null, + }, + { id: id2, name: '', completedAt: null, orderId: 1, parentId: null }, + ]) + setNewTask('') + }) + inputRefs.current[id2]?.focus() } const onDragEnd = event => { @@ -442,21 +613,21 @@ const SubTasks = ({ setTasks(items => { const oldIndex = items.findIndex(item => item.id === active.id) const newIndex = items.findIndex(item => item.id === over.id) - if (oldIndex === -1 || newIndex === -1) return items const activeItem = items[oldIndex] const overItem = items[newIndex] - const reorderedItems = arrayMove(items, oldIndex, newIndex) + const reordered = [...items] + reordered.splice(oldIndex, 1) + reordered.splice(newIndex, 0, activeItem) const parentId = overItem.parentId - const siblings = reorderedItems.filter(item => item.parentId === parentId) + const siblings = reordered.filter(item => item.parentId === parentId) - return reorderedItems.map(item => { - if (item.id === activeItem.id) { + return reordered.map(item => { + if (item.id === activeItem.id) return { ...item, parentId, orderId: siblings.indexOf(item) } - } return item.parentId === parentId ? { ...item, orderId: siblings.indexOf(item) } : item @@ -464,64 +635,64 @@ const SubTasks = ({ }) } - const handleKeyPress = event => { - if (event.key === 'Enter') { - handleAdd() - } - } - return ( - <> - - - - {topLevelTasks - .sort((a, b) => a.orderId - b.orderId) - .map((task, index) => ( - - ))} - {editMode && ( - - setNewTask(e.target.value)} - onKeyPress={handleKeyPress} - sx={{ flex: 1 }} - /> - - - - - )} - - - - + + + + {topLevelTasks + .sort((a, b) => a.orderId - b.orderId) + .map(task => ( + + ))} + + {editMode && tasks.length === 0 && ( + + setNewTask(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + handleAdd() + } + }} + sx={{ flex: 1 }} + /> + + + + + )} + + + ) } From d21ec9d72746145a2e0e8f228bb7323b1e3fccc2 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 12 Jul 2026 23:34:47 -0400 Subject: [PATCH 8/9] make choosing color match advance filter color selector --- src/views/Modals/Inputs/LabelModal.jsx | 65 +++++++++--------------- src/views/Modals/Inputs/ProjectModal.jsx | 57 +++++++++------------ 2 files changed, 47 insertions(+), 75 deletions(-) diff --git a/src/views/Modals/Inputs/LabelModal.jsx b/src/views/Modals/Inputs/LabelModal.jsx index 8080190..20509b0 100644 --- a/src/views/Modals/Inputs/LabelModal.jsx +++ b/src/views/Modals/Inputs/LabelModal.jsx @@ -1,12 +1,4 @@ -import { - Box, - Button, - FormControl, - Input, - Option, - Select, - Typography, -} from '@mui/joy' +import { Box, Button, FormControl, Input, Typography } from '@mui/joy' import { useEffect, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' @@ -125,39 +117,30 @@ function LabelModal({ isOpen, onClose, label }) { Color - + {error && ( diff --git a/src/views/Modals/Inputs/ProjectModal.jsx b/src/views/Modals/Inputs/ProjectModal.jsx index b3bc503..0b97feb 100644 --- a/src/views/Modals/Inputs/ProjectModal.jsx +++ b/src/views/Modals/Inputs/ProjectModal.jsx @@ -5,8 +5,6 @@ import { FormControl, FormLabel, Input, - Option, - Select, Stack, Textarea, Typography, @@ -223,39 +221,30 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { {/* Color Selection */} Project Color - + {/* Error Message */} From c9299777731a3cf2a671e34a223c279e33c75513 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 13 Jul 2026 00:17:27 -0400 Subject: [PATCH 9/9] add filter options for created and updated dates in SortAndGrouping component --- src/views/Chores/SortAndGrouping.jsx | 4 + src/views/Modals/Inputs/WriteNFCModal.jsx | 369 ++++++++++++++++------ 2 files changed, 284 insertions(+), 89 deletions(-) diff --git a/src/views/Chores/SortAndGrouping.jsx b/src/views/Chores/SortAndGrouping.jsx index 608fa05..09b86a0 100644 --- a/src/views/Chores/SortAndGrouping.jsx +++ b/src/views/Chores/SortAndGrouping.jsx @@ -83,6 +83,8 @@ const SortAndGrouping = ({ { name: 'Due Date', value: 'due_date' }, { name: 'Priority', value: 'priority' }, { name: 'Labels', value: 'labels' }, + { name: 'Created Date', value: 'created_date' }, + { name: 'Updated Date', value: 'updated_date' }, ] const filterItems = [ @@ -332,6 +334,8 @@ const SortAndGrouping = ({ { name: 'Due Date', value: 'due_date' }, { name: 'Priority', value: 'priority' }, { name: 'Labels', value: 'labels' }, + { name: 'Created Date', value: 'created_date' }, + { name: 'Updated Date', value: 'updated_date' }, ].map((item, index) => ( + {isWaiting && ( + <> + + + + )} + + {isSuccess ? ( + + ) : isError ? ( + + ) : ( + + )} + + + ) +} + function WriteNFCModal({ config }) { const { ResponsiveModal } = useResponsiveModal() - const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error' + const [nfcStatus, setNfcStatus] = useState('idle') const [errorMessage, setErrorMessage] = useState('') const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false) + const [copied, setCopied] = useState(false) const cancelScanRef = useRef(null) const isNative = Capacitor.isNativePlatform() @@ -45,6 +147,12 @@ function WriteNFCModal({ config }) { setNfcStatus('idle') } + const handleCopy = () => { + navigator.clipboard.writeText(getURL()) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + const writeToNFC = async () => { const url = getURL() @@ -78,99 +186,182 @@ function WriteNFCModal({ config }) { } else { setNfcStatus('error') setErrorMessage( - 'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.', + 'NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.', ) } } } - const renderBody = () => { - if (nfcStatus === 'success') { - return ( - - URL written to NFC tag successfully! - - ) - } + const isWaiting = nfcStatus === 'waiting_for_tag' || nfcStatus === 'writing' + const isSuccess = nfcStatus === 'success' + const isError = nfcStatus === 'error' - if (nfcStatus === 'waiting_for_tag') { - return ( - <> - - - - Hold your device near the NFC tag - - - - - ) - } + const title = isSuccess + ? 'Tag written!' + : isError + ? 'Something went wrong' + : isWaiting + ? 'Hold near NFC tag' + : 'Write to NFC' - return ( - <> - - {nfcStatus === 'error' - ? errorMessage - : 'Press the button below to write to NFC.'} - - { - navigator.clipboard.writeText(getURL()) - alert('URL copied to clipboard!') - }} - /> - } - /> - - setIsAutoCompleteWhenScan(e.target.checked)} - label='Auto-complete when scanned' - /> - - - - - - ) - } + const subtitle = isSuccess + ? 'Your NFC tag is ready to use.' + : isError + ? errorMessage + : isWaiting + ? 'Keep your device near the tag until complete.' + : 'Encode this task link onto any NFC tag.' return ( - - - {nfcStatus === 'success' ? 'Success!' : 'Write to NFC'} - - {renderBody()} - + <> + + + + {/* Icon */} + + + {/* Heading */} + + {title} + + + {subtitle} + + + {/* Idle / Error: URL + toggle + CTA */} + {!isWaiting && !isSuccess && ( + <> + + + Tag URL + + + + + } + /> + {copied && ( + + Copied! + + )} + + + + + + Auto-complete on scan + + + Mark task done when tag is tapped + + + setIsAutoCompleteWhenScan(e.target.checked)} + size='sm' + /> + + + + + + + + )} + + {/* Waiting state */} + {isWaiting && ( + + )} + + {/* Success state */} + {isSuccess && ( + + )} + + + ) }