From adca4c3af92d6ec111f126ceadb8bfdeadacd13c Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 25 Jul 2026 17:35:43 -0400 Subject: [PATCH 1/6] Attempt to fix capacitor sqlite error and update social login to make sure we exclude any facebook sdk --- .gitignore | 6 +++++- android/app/build.gradle | 4 ++-- android/app/src/main/AndroidManifest.xml | 6 ++++++ android/build.gradle | 6 ++++++ capacitor.config.ts | 16 ++++++++++++++++ ios/App/App.xcodeproj/project.pbxproj | 16 ++++++++-------- ios/App/Podfile.lock | 22 ++-------------------- package-lock.json | 12 ++++++------ package.json | 4 ++-- 9 files changed, 53 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 78581db..67b89fe 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,8 @@ fastlane/report.xml fastlane/Preview.html fastlane/.env fastlane/*.log -vendor/bundle \ No newline at end of file +vendor/bundle + +# Claude Code skills cache +.agents/ +skills-lock.json \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 4d63416..4a1ffc9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.donetick.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 37 - versionName "1.2.16" + versionCode 49 + versionName "1.2.28" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a34d79..8287cc7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -114,6 +114,12 @@ + + + + + + diff --git a/android/build.gradle b/android/build.gradle index 97e6322..57f4134 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -22,6 +22,12 @@ allprojects { google() mavenCentral() } + + configurations.configureEach { + // The app does not use Advertising ID. Exclude RevenueCat's transitive + // ads identifier dependency so Play Console does not require an AD_ID declaration. + exclude group: 'com.google.android.gms', module: 'play-services-ads-identifier' + } } task clean(type: Delete) { diff --git a/capacitor.config.ts b/capacitor.config.ts index a585c19..2cc9037 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -16,6 +16,22 @@ const config: CapacitorConfig = { iconColor: '#488AFF', sound: 'beep.wav', }, + CapacitorSQLite: { + // The offline cache opens databases with encrypted: false / no-encryption. + // @capacitor-community/sqlite defaults native encryption to true when this + // block is absent; on Android that can make plugin load fail with + // "CapacitorSQLitePlugin: null" before JS gets a chance to open the DB. + iosIsEncryption: false, + androidIsEncryption: false, + }, + SocialLogin: { + providers: { + google: true, + facebook: false, + apple: true, + twitter: false, + }, + }, // GoogleAuth: { // scopes: ['profile', 'email', 'openid'], // clientId: process.env.VITE_APP_GOOGLE_CLIENT_ID, diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index bedc462..3f0a3f1 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -462,12 +462,12 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 6UJJ78R3BS; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.2.16; + MARKETING_VERSION = 1.2.28; PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -485,12 +485,12 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)"; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 6UJJ78R3BS; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.2.16; + MARKETING_VERSION = 1.2.28; PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -504,12 +504,12 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 6UJJ78R3BS; INFOPLIST_FILE = DonetickWidget/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MARKETING_VERSION = 1.2.16; + MARKETING_VERSION = 1.2.28; PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -527,12 +527,12 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)"; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 6UJJ78R3BS; INFOPLIST_FILE = DonetickWidget/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MARKETING_VERSION = 1.2.16; + MARKETING_VERSION = 1.2.28; PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 5f6f755..64a29e0 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -43,20 +43,10 @@ PODS: - Capacitor - CapgoCapacitorNfc (8.2.2): - Capacitor - - CapgoCapacitorSocialLogin (8.3.36): + - CapgoCapacitorSocialLogin (8.3.38): - Alamofire (~> 5.10.2) - Capacitor - - FBSDKCoreKit (~> 18.0) - - FBSDKLoginKit (~> 18.0) - GoogleSignIn (~> 9.0.0) - - FBAEMKit (18.1.0): - - FBSDKCoreKit_Basics (= 18.1.0) - - FBSDKCoreKit (18.1.0): - - FBAEMKit (= 18.1.0) - - FBSDKCoreKit_Basics (= 18.1.0) - - FBSDKCoreKit_Basics (18.1.0) - - FBSDKLoginKit (18.1.0): - - FBSDKCoreKit (= 18.1.0) - FirebaseCore (12.15.0): - FirebaseCoreInternal (~> 12.15.0) - GoogleUtilities/Environment (~> 8.1) @@ -172,10 +162,6 @@ SPEC REPOS: - Alamofire - AppAuth - AppCheckCore - - FBAEMKit - - FBSDKCoreKit - - FBSDKCoreKit_Basics - - FBSDKLoginKit - FirebaseCore - FirebaseCoreInternal - FirebaseInstallations @@ -255,11 +241,7 @@ SPEC CHECKSUMS: CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32 CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8 CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027 - CapgoCapacitorSocialLogin: adffda2bec52a765bdaebf3fa9a6083bd9341d60 - FBAEMKit: 64088ff0380f50e4a56e2ee07d984f7f1aef7595 - FBSDKCoreKit: 8390ea3a8fac186f444275f31d7512e760b47cba - FBSDKCoreKit_Basics: 3a0005c78b355388bf2df5c6dbe6381b77ea4be3 - FBSDKLoginKit: d8e2711a3a03b0026703735c8d28a2b5a0e1f4fd + CapgoCapacitorSocialLogin: 5de0c9295188cbd9116d13e4581494f3bbaa3414 FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992 FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52 diff --git a/package-lock.json b/package-lock.json index ee5471e..9829046 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "donetick", - "version": "1.2.15", + "version": "1.2.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "donetick", - "version": "1.2.15", + "version": "1.2.27", "dependencies": { "@capacitor-community/sqlite": "^8.0.0", "@capacitor/android": "^8.0.0", @@ -23,7 +23,7 @@ "@capacitor/status-bar": "^8.0.0", "@capgo/capacitor-document-scanner": "^8.4.0", "@capgo/capacitor-nfc": "^8.0.0", - "@capgo/capacitor-social-login": "^8.0.0", + "@capgo/capacitor-social-login": "^8.3.38", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -2135,9 +2135,9 @@ } }, "node_modules/@capgo/capacitor-social-login": { - "version": "8.3.36", - "resolved": "https://registry.npmjs.org/@capgo/capacitor-social-login/-/capacitor-social-login-8.3.36.tgz", - "integrity": "sha512-Y4HYjEJablzbHHwPBrbC2UvHVPsR0bAyCAqBzVBoUEOf0UFHAHyTP3gD9JUTKP/iERU8I+6IA2RWtLqDePCNuA==", + "version": "8.3.38", + "resolved": "https://registry.npmjs.org/@capgo/capacitor-social-login/-/capacitor-social-login-8.3.38.tgz", + "integrity": "sha512-SHrQ9gVJ2oCxf6dQu6Bq4PwdzYIYX7OVuUNyuGPFxLWPR8DCJM50gEhdAmunzEVd1vlty0ShH+84IOU5FEHLPg==", "license": "MPL-2.0", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index 11c19fd..f610997 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "donetick", "private": true, - "version": "1.2.16", + "version": "1.2.28", "type": "module", "engines": { "node": ">=20.0.0", @@ -53,7 +53,7 @@ "@capacitor/status-bar": "^8.0.0", "@capgo/capacitor-document-scanner": "^8.4.0", "@capgo/capacitor-nfc": "^8.0.0", - "@capgo/capacitor-social-login": "^8.0.0", + "@capgo/capacitor-social-login": "^8.3.38", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", From ad1f85ffccb571aa486093a22cde7db0ea0c755b Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 26 Jul 2026 13:26:51 -0400 Subject: [PATCH 2/6] Update Gemfile --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8ca03a3..a123142 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -230,4 +230,4 @@ DEPENDENCIES fastlane BUNDLED WITH - 1.17.2 + 2.4.10 From 5a4f67d70a46e9444531d6c1cb5e8a3dea0e2ac0 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 27 Jul 2026 22:45:49 -0400 Subject: [PATCH 3/6] Add back Quick access to the filters --- .../Chores/components/ChoreToolbarPrototype.jsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index 9b2c3d1..f6a144c 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -61,6 +61,7 @@ import FilterBuilderContent, { } from './FilterBuilderContent' import SearchBar from './SearchBar' import ProjectSelector from '../../components/ProjectSelector' +import CustomFilterChips from './CustomFilterChips' // ─── sub-components for the Display sheet ──────────────────────────────────── @@ -599,7 +600,19 @@ const ChoreToolbar = ({ - {/* ── Row 2: active filter chips ──────────────────────────────────────── */} + {/* ── Row 2: pinned filters quick access ──────────────────────────────── */} + {!hasAnyActive && ( + + )} + + {/* ── Row 3: active filter chips ──────────────────────────────────────── */} {hasAnyActive && ( Date: Mon, 27 Jul 2026 23:43:39 -0400 Subject: [PATCH 4/6] reduce the noise by making the color just a small badge --- .../components/ChoreToolbarPrototype.jsx | 10 +- .../Chores/components/CustomFilterChips.jsx | 256 +++++++++--------- 2 files changed, 138 insertions(+), 128 deletions(-) diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index f6a144c..6b97276 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -601,7 +601,11 @@ const ChoreToolbar = ({ {/* ── Row 2: pinned filters quick access ──────────────────────────────── */} - {!hasAnyActive && ( + {/* Stays visible even while a saved filter is active (it self-highlights) + so switching between pinned filters never requires clearing first. + Only steps aside for an ad-hoc/temp condition set, which has no + matching pinned identity to show. */} + {tempConditionCount === 0 && ( )} - {/* ── Row 3: active filter chips ──────────────────────────────────────── */} - {hasAnyActive && ( + {/* ── Row 3: active (temp/ad-hoc) filter chips ──────────────────────────── */} + {tempConditionCount > 0 && ( { - if (a.isPinned && !b.isPinned) return -1 - if (!a.isPinned && b.isPinned) return 1 - return (b.usageCount || 0) - (a.usageCount || 0) - }) - if (sortedFilters.filter(f => f.isPinned).length === 0) return null + const pinnedFilters = filters + .filter(f => f.isPinned) + .sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0)) + + if (pinnedFilters.length === 0) return null return ( - - {sortedFilters.map(filter => { - const isActive = activeFilterId === filter.id - const hasWarning = !filter.isValid - const hasCustomColor = !!filter.color && !hasWarning - const textColor = hasCustomColor - ? getTextColorFromBackgroundColor(filter.color) - : undefined + + + {pinnedFilters.map(filter => { + const isActive = activeFilterId === filter.id + const hasWarning = !filter.isValid + const badgeColor = filter.color || 'var(--joy-palette-neutral-400)' + const badgeTextColor = filter.color + ? getTextColorFromBackgroundColor(filter.color) + : '#ffffff' + const displayCount = + filter.count > 99 ? '99+' : (filter.count ?? 0) - return ( - 0 ? ` (${filter.overdueCount} overdue)` : ''}` - } - placement='bottom' - > -
!hasWarning && onFilterClick(filter.id)}> + return ( + 0 ? ` (${filter.overdueCount} overdue)` : ''}` + } + placement='bottom' + > handleContextMenu(e, filter)} + onClick={() => !hasWarning && onFilterClick(filter.id)} sx={{ cursor: hasWarning ? 'not-allowed' : 'pointer', - transition: 'all 0.2s ease', - px: 1.0, - py: 0.5, + transition: 'background-color 0.15s ease, color 0.15s ease', + px: 1, height: 32, - display: 'flex', - alignItems: 'center', - - opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85, - ...(hasCustomColor && { - backgroundColor: `${filter.color} !important`, - color: `${textColor} !important`, - '&:hover': { - backgroundColor: `${filter.color} !important`, - filter: 'brightness(0.95)', - opacity: 1, - }, - }), + flexShrink: 0, + fontWeight: isActive ? 600 : 500, + '&:hover': { + backgroundColor: isActive ? undefined : 'neutral.softHoverBg', + }, }} startDecorator={ - - {isActive ? ( - - ) : ( - - {filter.count} - - )} - + !hasWarning && ( + + {isActive ? ( + + ) : ( + + {displayCount} + + )} + + ) + } + endDecorator={ + { + e.stopPropagation() + handleContextMenu(e, filter) + }} + sx={{ + '--IconButton-size': '20px', + ml: 0.25, + opacity: 0.6, + '&:hover': { opacity: 1, backgroundColor: 'transparent' }, + }} + > + + } - // endDecorator={ - // - // {hasWarning && } - // {!hasWarning && filter.overdueCount > 0 && ( - // - // {filter.overdueCount} - // - // )} - // - // } > {filter.name} -
-
- ) - })} + + ) + })} - { + e.preventDefault() + e.stopPropagation() + navigate('/filters') + }} + > + + +
+ + {/* Fade hint that more chips are scrollable off the trailing edge */} + { - e.preventDefault() - e.stopPropagation() - navigate('/filters') - }} - > - - + /> Date: Mon, 27 Jul 2026 23:55:27 -0400 Subject: [PATCH 5/6] Fix Adding labels to match the way we have it in EditChore --- src/views/TestView/AutocompleteDropdown.jsx | 89 ++++++++++++-------- src/views/components/AddTaskModal.jsx | 34 ++++++-- src/views/components/CustomParsers.js | 82 +++++++++--------- src/views/components/SmartTaskTitleInput.jsx | 83 ++++++++++++------ 4 files changed, 181 insertions(+), 107 deletions(-) diff --git a/src/views/TestView/AutocompleteDropdown.jsx b/src/views/TestView/AutocompleteDropdown.jsx index afe96b3..685800e 100644 --- a/src/views/TestView/AutocompleteDropdown.jsx +++ b/src/views/TestView/AutocompleteDropdown.jsx @@ -1,4 +1,5 @@ // AutocompleteDropdown.jsx +import { Add } from '@mui/icons-material' import { Divider, Menu, MenuItem } from '@mui/joy' import React, { useEffect } from 'react' @@ -8,6 +9,7 @@ const AutocompleteDropdown = ({ selectedIndex, onSelectSuggestion, onMouseEnterSuggestion, // Added for hover selection + onCreateSuggestion, // Called when the "Create new" row is chosen parentRefer, // Ref to the dropdown element }) => { // Scroll selected item into view @@ -26,9 +28,28 @@ const AutocompleteDropdown = ({ return null // Don't render if no suggestions } + const filteredOptions = suggestions.options.filter(option => { + if (typeof option === 'string') { + return option.toLowerCase().includes(currentValue.toLowerCase()) + } + return option[suggestions.display] + .toLowerCase() + .includes(currentValue.toLowerCase()) + }) + + const trimmedValue = currentValue.trim() + const hasExactMatch = filteredOptions.some(option => { + const optionText = suggestions.display + ? option[suggestions.display] + : option + return optionText.toLowerCase() === trimmedValue.toLowerCase() + }) + const showCreateOption = + suggestions.creatable && trimmedValue.length > 0 && !hasExactMatch + return ( {}} @@ -42,45 +63,45 @@ const AutocompleteDropdown = ({ zIndex: 1300, }} > - {suggestions?.options - .filter(option => { - if (typeof option === 'string') { - return option.toLowerCase().includes(currentValue.toLowerCase()) - } - return option[suggestions.display] - .toLowerCase() - .includes(currentValue.toLowerCase()) - }) - .map((option, index) => ( + {filteredOptions.map((option, index) => ( + onSelectSuggestion(option)} + onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover + className={selectedIndex === index ? 'selected' : ''} // Add class for selected item + sx={{ + cursor: 'pointer', + backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit', + }} + > + {suggestions.display ? option[suggestions.display] : option} + + ))} + {showCreateOption && ( + <> + {filteredOptions.length > 0 && } onSelectSuggestion(option)} - onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover - className={selectedIndex === index ? 'selected' : ''} // Add class for selected item + selected={selectedIndex === filteredOptions.length} + onClick={() => onCreateSuggestion(trimmedValue)} + onMouseEnter={() => onMouseEnterSuggestion(filteredOptions.length)} + className={ + selectedIndex === filteredOptions.length ? 'selected' : '' + } sx={{ cursor: 'pointer', - backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit', + backgroundColor: + selectedIndex === filteredOptions.length + ? 'gray.800' + : 'inherit', + gap: 0.5, }} > - {suggestions.display ? option[suggestions.display] : option} + + Create "{trimmedValue}" - ))} - - {/* - { - onSelectSuggestion(currentValue) - }} - sx={{ - cursor: 'pointer', - backgroundColor: 'gray.800', - }} - > - - Add new Label - */} + + )} ) } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 30ed6f5..bc2daa7 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -3,10 +3,12 @@ import { Box, Button, Typography } from '@mui/joy' import { useMediaQuery } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' +import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useRef, useState } from 'react' import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useCreateChore } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' +import { CreateLabel } from '../../utils/Fetcher' import { isPlusAccount } from '../../utils/Helpers' import { generateUUID } from '../../utils/UUID' import { useLabels } from '../Labels/LabelQueries' @@ -24,7 +26,7 @@ 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 LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors' import AdvancedOptionsSection, { AdvancedOptionsTrigger, } from './AdvancedOptionsSection' @@ -66,9 +68,22 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { useCircleMembers() const { isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() + const queryClient = useQueryClient() const { data: userProfile } = useUserProfile() + const handleCreateLabel = useCallback( + name => { + const color = + LABEL_COLORS[1 + Math.floor(Math.random() * (LABEL_COLORS.length - 1))] + .value + CreateLabel({ name, color }) + .then(() => queryClient.invalidateQueries(['labels'])) + .catch(error => console.error('Error creating label:', error)) + }, + [queryClient], + ) + // Get initial project from localStorage (current active project) const getInitialProject = () => { const saved = localStorage.getItem('selectedProject') @@ -378,11 +393,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { if (priority.result) setPriority(parseInt(priority.result, 10)) if (pointsParsed.result) setPoints(pointsParsed.result) if (labels.result) { - // parseLabels returns array of label objects, extract their IDs - const labelIds = labels.result - .filter(label => label.id) // Only labels with IDs (existing labels) - .map(label => label.id) - setLabelsV2(labelIds) + // parseLabels only returns #mentions matched to an existing label + setLabelsV2(labels.result) } if (assigneesResult.isAnyone) { @@ -856,6 +868,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { value: 'id', display: 'name', options: userLabels ? userLabels : [], + creatable: true, + onCreate: handleCreateLabel, }, '!': { value: 'id', @@ -954,8 +968,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { /> label.id)} + onChange={ids => + setLabelsV2( + (userLabels || []).filter(label => ids.includes(label.id)), + ) + } onClear={() => setLabelsV2([])} labels={userLabels || []} /> diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js index df9a5d1..317be0e 100644 --- a/src/views/components/CustomParsers.js +++ b/src/views/components/CustomParsers.js @@ -97,64 +97,70 @@ export const parsePriority = inputSentence => { } } export const parseLabels = (inputSentence, userLabels) => { - let sentence = inputSentence.toLowerCase() const currentLabels = [] - const newLabels = [] + const matchedRanges = [] const allHighlights = [] - // Find all #label patterns in the sentence - // Use [\p{L}\p{N}_]+ to support Unicode letters (including umlauts) and numbers - const labelPattern = /#([\p{L}\p{N}_]+)/giu - const matches = [...inputSentence.matchAll(labelPattern)] + // Labels can contain spaces (e.g. "New Label"), so a plain word-boundary + // regex can't capture them — check each '#' against real label names + // instead, longest name first so "New Label" wins over a label named "New". + const sortedLabels = [...userLabels].sort( + (a, b) => b.name.length - a.name.length, + ) + const isWordChar = ch => ch !== undefined && /[\p{L}\p{N}_]/u.test(ch) - for (const match of matches) { - const labelName = match[1] - const fullMatch = match[0] - const startIndex = match.index + const hashPattern = /#/g + let hashMatch + while ((hashMatch = hashPattern.exec(inputSentence)) !== null) { + const startIndex = hashMatch.index + const rest = inputSentence.slice(startIndex + 1) - // Check if this label already exists - const existingLabel = userLabels.find( - label => label.name.toLowerCase() === labelName.toLowerCase(), - ) + const existingLabel = sortedLabels.find(label => { + const name = label.name + if (rest.toLowerCase().slice(0, name.length) !== name.toLowerCase()) { + return false + } + // Require a non-word boundary right after the name so "New" doesn't + // match inside a longer typed word like "Newer". + return !isWordChar(rest[name.length]) + }) - if (existingLabel) { - currentLabels.push(existingLabel) - } else { - // Create a new label object for new labels - newLabels.push({ - name: labelName, - color: '#3b82f6', // Default blue color - isNew: true, - }) - } + // Unmatched #mentions (no existing label, not created) are left as plain + // text — only #mentions resolving to a real label are extracted/cleaned. + if (!existingLabel) continue - allHighlights.push({ - text: fullMatch, + const fullMatch = `#${existingLabel.name}` + currentLabels.push(existingLabel) + matchedRanges.push({ start: startIndex, end: startIndex + fullMatch.length, }) - - // Remove the label from the sentence - sentence = sentence.replace(fullMatch.toLowerCase(), '') + allHighlights.push({ + text: inputSentence.slice(startIndex, startIndex + fullMatch.length), + start: startIndex, + end: startIndex + fullMatch.length, + }) + hashPattern.lastIndex = startIndex + fullMatch.length } - const allLabels = [...currentLabels, ...newLabels] + if (currentLabels.length > 0) { + let cleanedSentence = inputSentence + for (const range of matchedRanges + .slice() + .sort((a, b) => b.start - a.start)) { + cleanedSentence = + cleanedSentence.slice(0, range.start) + cleanedSentence.slice(range.end) + } - if (allLabels.length > 0) { return { - result: allLabels, - newLabels: newLabels, + result: currentLabels, highlight: allHighlights, - cleanedSentence: inputSentence - .replace(labelPattern, '') - .replace(/\s+/g, ' ') - .trim(), + cleanedSentence: cleanedSentence.replace(/\s+/g, ' ').trim(), } } return { result: null, - newLabels: [], cleanedSentence: inputSentence, } } diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx index 39fa034..9558b5f 100644 --- a/src/views/components/SmartTaskTitleInput.jsx +++ b/src/views/components/SmartTaskTitleInput.jsx @@ -111,46 +111,71 @@ const SmartTaskTitleInput = ({ setCursorPosition(e.target.selectionStart) } + const selectSuggestionText = suggestionValue => { + const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}` + onChange(newValue) + titleInputRef.current.value = newValue + + setShowSuggestions(false) + + const newCursorPosition = + cursorPosition - lastWord.length + suggestionValue.length + 1 + titleInputRef.current.setSelectionRange( + newCursorPosition, + newCursorPosition, + ) + } + const handleTextareaKeyDown = e => { if (showSuggestions) { - const currentSuggestions = suggestions[suggestionTrigger].options.filter( - option => { - if (typeof option === 'string') { - return option.toLowerCase().includes(lastWord.toLowerCase()) - } - return option[suggestions[suggestionTrigger].display] - .toLowerCase() - .includes(lastWord.toLowerCase()) - }, - ) + const activeSuggestions = suggestions[suggestionTrigger] + const currentSuggestions = activeSuggestions.options.filter(option => { + if (typeof option === 'string') { + return option.toLowerCase().includes(lastWord.toLowerCase()) + } + return option[activeSuggestions.display] + .toLowerCase() + .includes(lastWord.toLowerCase()) + }) + + const trimmedWord = lastWord.trim() + const hasExactMatch = currentSuggestions.some(option => { + const optionText = activeSuggestions.display + ? option[activeSuggestions.display] + : option + return optionText.toLowerCase() === trimmedWord.toLowerCase() + }) + const showCreateOption = + activeSuggestions.creatable && trimmedWord.length > 0 && !hasExactMatch + const optionCount = currentSuggestions.length + (showCreateOption ? 1 : 0) if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() + if (optionCount === 0) return const newIndex = e.key === 'ArrowDown' - ? (selectedSuggestionIndex + 1) % currentSuggestions.length - : (selectedSuggestionIndex - 1 + currentSuggestions.length) % - currentSuggestions.length + ? (selectedSuggestionIndex + 1) % optionCount + : (selectedSuggestionIndex - 1 + optionCount) % optionCount setSelectedSuggestionIndex(newIndex) } else if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault() + if ( + showCreateOption && + selectedSuggestionIndex === currentSuggestions.length + ) { + e.preventDefault() + selectSuggestionText(trimmedWord) + activeSuggestions.onCreate?.(trimmedWord) + return + } + const selectedSuggestion = currentSuggestions[selectedSuggestionIndex] - const suggestionValue = suggestions[suggestionTrigger].display - ? selectedSuggestion[suggestions[suggestionTrigger].display] + const suggestionValue = activeSuggestions.display + ? selectedSuggestion?.[activeSuggestions.display] : selectedSuggestion if (suggestionValue) { - const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}` - onChange(newValue) - titleInputRef.current.value = newValue - - setShowSuggestions(false) - - const newCursorPosition = cursorPosition + suggestionValue.length + 1 - titleInputRef.current.setSelectionRange( - newCursorPosition, - newCursorPosition, - ) + e.preventDefault() + selectSuggestionText(suggestionValue) } } else if (e.key === 'Escape') { e.preventDefault() @@ -349,6 +374,10 @@ const SmartTaskTitleInput = ({ ) setShowSuggestions(false) }} + onCreateSuggestion={name => { + selectSuggestionText(name) + suggestions[suggestionTrigger].onCreate?.(name) + }} parentRefer={dropdownRef} /> )} From 8cba22024a4301eb2e7a129d793d960c7771d175 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 28 Jul 2026 00:03:04 -0400 Subject: [PATCH 6/6] Fix Choretoolbar display active when it should always stay neutral --- src/views/Chores/MyChores.jsx | 2 +- src/views/Chores/components/ChoreToolbarPrototype.jsx | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index bc9b546..c0d3294 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -101,7 +101,7 @@ const MyChores = () => { const searchInputRef = useRef(null) const [searchInputFocus, setSearchInputFocus] = useState(0) const [selectedChoreSection, setSelectedChoreSection] = useState( - localStorage.getItem('selectedChoreSection') || 'due_date', + localStorage.getItem('selectedChoreSection') || 'default', ) const [openChoreSections, setOpenChoreSections] = useState(() => { try { diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index 6b97276..0c5b5d1 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -482,13 +482,6 @@ const ChoreToolbar = ({ // ── display sheet helpers ──────────────────────────────────────────────────── const filterActive = activeFilterId != null || tempConditionCount > 0 - const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0 - const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0 - const displayActive = - selectedGroupBy !== 'default' || - viewMode !== 'default' || - projectActive > 0 || - assigneeActive > 0 const groupByOptions = [ { value: 'default', label: 'Smart' }, @@ -560,8 +553,8 @@ const ChoreToolbar = ({ {/* Display button — View + Group combined */} setDisplaySheetOpen(true)}