From cf4d0565c5d0a0dca8aa77ad99e0785c61bd5aea Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 29 Aug 2025 00:50:54 -0400 Subject: [PATCH] feat: Enhance Signup and Chores functionality - Added query invalidation on signup to refresh user profile data. - Improved MyChores component to ensure data is loaded before rendering. - Introduced dynamic sidepanel configuration with drag-and-drop functionality. - Created TasksByAssigneeCard to visualize tasks assigned to users. - Updated MFASettings and Settings components for better structure and usability. - Implemented SettingsOverview for a comprehensive settings navigation experience. - Added SidepanelSettings for customizing sidepanel card visibility and order. - Refactored NavBar to include user profile avatar and improved layout. --- android/variables.gradle | 10 +- src/components/UserProfileAvatar.jsx | 517 ++++++++++++++++++ src/components/animations/PageTransition.jsx | 9 +- src/components/common/FadeModal.jsx | 2 +- src/contexts/RouterContext.jsx | 5 + src/utils/Colors.jsx | 5 + src/utils/SidepanelConfig.js | 65 +++ src/utils/StatusBarManager.js | 218 ++++++++ .../Authorization/ForgotPasswordView.jsx | 159 +++--- src/views/Authorization/LoginView.jsx | 54 +- src/views/Authorization/Signup.jsx | 12 +- src/views/Chores/MyChores.jsx | 8 +- src/views/Chores/Sidepanel.jsx | 91 ++- src/views/Chores/TasksByAssigneeCard.jsx | 398 ++++++++++++++ src/views/Settings/MFASettings.jsx | 2 +- src/views/Settings/Settings.jsx | 104 +++- src/views/Settings/SettingsOverview.jsx | 300 ++++++++++ src/views/Settings/SidepanelSettings.jsx | 258 +++++++++ src/views/components/NavBar.jsx | 48 +- 19 files changed, 2077 insertions(+), 188 deletions(-) create mode 100644 src/components/UserProfileAvatar.jsx create mode 100644 src/utils/SidepanelConfig.js create mode 100644 src/utils/StatusBarManager.js create mode 100644 src/views/Chores/TasksByAssigneeCard.jsx create mode 100644 src/views/Settings/SettingsOverview.jsx create mode 100644 src/views/Settings/SidepanelSettings.jsx diff --git a/android/variables.gradle b/android/variables.gradle index 8ef305d..9930199 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -1,9 +1,9 @@ ext { - minSdkVersion = 22 - compileSdkVersion = 34 - targetSdkVersion = 34 - androidxActivityVersion = '1.8.0' - androidxAppCompatVersion = '1.6.1' + minSdkVersion = 24 + compileSdkVersion = 35 + targetSdkVersion = 35 + androidxActivityVersion = '1.9.2' + androidxAppCompatVersion = '1.7.0' androidxCoordinatorLayoutVersion = '1.2.0' androidxCoreVersion = '1.12.0' androidxFragmentVersion = '1.6.2' diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx new file mode 100644 index 0000000..0801636 --- /dev/null +++ b/src/components/UserProfileAvatar.jsx @@ -0,0 +1,517 @@ +import { + AdminPanelSettings, + DarkModeOutlined, + Email, + LightModeOutlined, + Logout, + Person, + Settings, + SwapHoriz, + Tune, + WorkspacePremium, +} from '@mui/icons-material' +import { + Avatar, + Box, + Divider, + Dropdown, + ListItemContent, + ListItemDecorator, + Menu, + MenuButton, + MenuItem, + Sheet, + Typography, + useColorScheme, +} from '@mui/joy' +import moment from 'moment' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useImpersonateUser } from '../contexts/ImpersonateUserContext' +import useStickyState from '../hooks/useStickyState' +import { useCircleMembers, useUserProfile } from '../queries/UserQueries' +import { isPlusAccount } from '../utils/Helpers' +import UserModal from '../views/Modals/Inputs/UserModal' +import SubscriptionModal from './SubscriptionModal' + +const UserProfileAvatar = () => { + const navigate = useNavigate() + const { mode, setMode } = useColorScheme() + const { data: userProfile } = useUserProfile() + const { impersonatedUser, setImpersonatedUser } = useImpersonateUser() + const { data: circleMembersData } = useCircleMembers() + const [isModalOpen, setIsModalOpen] = useState(false) + const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false) + const [isAdmin, setIsAdmin] = useState(false) + const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode') + + useEffect(() => { + if (userProfile && userProfile?.id) { + const members = circleMembersData?.res || [] + const isUserAdmin = members.some( + member => + member.userId === userProfile?.id && + (member.role === 'admin' || member.role === 'manager'), + ) + setIsAdmin(isUserAdmin) + } + }, [userProfile, circleMembersData]) + + if (!userProfile) return null + + const currentUser = impersonatedUser || userProfile + const isImpersonating = !!impersonatedUser + const isPlusUser = isPlusAccount(userProfile) + + const getSubscriptionStatus = () => { + if (!userProfile) return 'Free' + + if (userProfile.subscription === 'active') { + return 'Plus' + } + + if ( + userProfile.subscription === 'cancelled' && + moment().isBefore(userProfile.expiration) + ) { + return 'Plus (expires soon)' + } + + return 'Free' + } + + const handleLogout = () => { + localStorage.removeItem('ca_token') + localStorage.removeItem('ca_expiration') + window.location.href = '/login' + } + + const handleSupportEmail = () => { + window.location.href = 'mailto:support@donetick.com' + } + + const isDarkMode = themeMode === 'dark' + + const handleThemeToggle = () => { + const newThemeMode = isDarkMode ? 'light' : 'dark' + setThemeMode(newThemeMode) + setMode(newThemeMode) + } + + return ( + <> + + + + {isImpersonating ? ( + + + + + + + + ) : ( + + )} + + + + + + + + + {currentUser?.displayName || currentUser?.name} + + + + {currentUser?.email} + + {isPlusUser && ( + + {getSubscriptionStatus()} + + )} + + {isImpersonating && ( + + + + Impersonating + + + )} + + + + + {isAdmin && ( + <> + setIsModalOpen(true)} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + {isImpersonating ? 'Switch User' : 'Impersonate User'} + + + Act as another user + + + + + {isImpersonating && ( + setImpersonatedUser(null)} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + Stop Impersonating + + + Return to your account + + + + )} + + + + )} + + navigate('/settings')} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + Settings + + + Account & preferences + + + + + navigate('/settings/detailed#sidepanel')} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + Sidepanel Settings + + + Customize layout & cards + + + + + + + {isDarkMode ? : } + + + + {isDarkMode ? 'Switch to Light' : 'Switch to Dark'} + + + Toggle theme appearance + + + + + {!isPlusUser && ( + setIsSubscriptionModalOpen(true)} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + '&:hover': { + backgroundColor: 'var(--joy-palette-warning-softHoverBg)', + }, + }} + > + + + + + + Upgrade to Plus + + Unlock premium features + + + )} + + + + + + + + Support + + + support@donetick.com + + + + + + + + + + + + + Logout + + + + + + + { + setImpersonatedUser(user) + setIsModalOpen(false) + }} + onClose={() => setIsModalOpen(false)} + /> + + setIsSubscriptionModalOpen(false)} + /> + + ) +} + +export default UserProfileAvatar diff --git a/src/components/animations/PageTransition.jsx b/src/components/animations/PageTransition.jsx index cf7c580..0ddd762 100644 --- a/src/components/animations/PageTransition.jsx +++ b/src/components/animations/PageTransition.jsx @@ -86,7 +86,14 @@ const PageTransition = ({ children }) => { }} unmountOnExit > -
{children}
+
+ {children} +
) diff --git a/src/components/common/FadeModal.jsx b/src/components/common/FadeModal.jsx index e207ce9..0960201 100644 --- a/src/components/common/FadeModal.jsx +++ b/src/components/common/FadeModal.jsx @@ -41,7 +41,7 @@ const FadeModal = ({ size={size} sx={{ zIndex: Z_INDEX.MODAL_CONTENT, - minWidth: fullWidth ? '100%' : 'auto', + minWidth: fullWidth ? '90%' : 'auto', animation: open ? 'modalFadeIn 0.35s forwards' : 'modalFadeOut 0.25s forwards', diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index da38c15..5f9f76b 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -2,6 +2,7 @@ import App from '@/App' import ChoreEdit from '@/views/ChoreEdit/ChoreEdit' import Error from '@/views/Error' import Settings from '@/views/Settings/Settings' +import SettingsOverview from '@/views/Settings/SettingsOverview' import { Capacitor } from '@capacitor/core' import { RouterProvider, createBrowserRouter } from 'react-router-dom' import AuthenticationLoading from '../views/Authorization/Authenticating' @@ -49,6 +50,10 @@ const Router = createBrowserRouter([ }, { path: '/settings', + element: , + }, + { + path: '/settings/detailed', element: , }, { diff --git a/src/utils/Colors.jsx b/src/utils/Colors.jsx index 842f2bf..33aa7dc 100644 --- a/src/utils/Colors.jsx +++ b/src/utils/Colors.jsx @@ -60,6 +60,11 @@ export const TASK_COLOR = { MISSED: '#F03A47', UPCOMING: '#AF5B5B', SKIPPED: '#E2C2FF', + IN_PROGRESS: '#00bcd4', + // PENDING_REVIEW: '#b39ddb', + OVERDUE: '#F03A47', + SCHEDULED: '#10B982', + PENDING_REVIEW: '#8B6CE1', // For the calendar OVERDUE: '#F03A47', diff --git a/src/utils/SidepanelConfig.js b/src/utils/SidepanelConfig.js new file mode 100644 index 0000000..9916c8a --- /dev/null +++ b/src/utils/SidepanelConfig.js @@ -0,0 +1,65 @@ +export const DEFAULT_SIDEPANEL_CONFIG = [ + { + id: 'welcome', + name: 'Welcome Card', + description: 'Shows greeting and quick stats', + iconName: 'WavingHand', + enabled: true, + order: 0, + }, + { + id: 'assignees', + name: 'Tasks by Assignee', + description: 'Groups tasks by who they are assigned to', + iconName: 'Person', + enabled: true, + order: 1, + }, + { + id: 'calendar', + name: 'Calendar View', + description: 'Shows tasks in a calendar format', + iconName: 'CalendarMonth', + enabled: true, + order: 2, + }, + { + id: 'activities', + name: 'Recent Activities', + description: 'Shows recent task completions and activities', + iconName: 'History', + enabled: true, + order: 3, + }, + { + id: 'weeklyGoals', + name: 'Weekly Goals', + description: 'Shows weekly progress and family completion stats', + iconName: 'EmojiEvents', + enabled: true, + order: 4, + }, +] + +export const getSidepanelConfig = () => { + const saved = localStorage.getItem('sidepanelConfig') + if (saved) { + try { + return JSON.parse(saved) + } catch (error) { + console.error('Error parsing sidepanel config:', error) + return DEFAULT_SIDEPANEL_CONFIG + } + } + return DEFAULT_SIDEPANEL_CONFIG +} + +export const saveSidepanelConfig = config => { + localStorage.setItem('sidepanelConfig', JSON.stringify(config)) + window.dispatchEvent(new Event('sidepanelConfigChanged')) +} + +export const resetSidepanelConfig = () => { + saveSidepanelConfig(DEFAULT_SIDEPANEL_CONFIG) + return DEFAULT_SIDEPANEL_CONFIG +} \ No newline at end of file diff --git a/src/utils/StatusBarManager.js b/src/utils/StatusBarManager.js new file mode 100644 index 0000000..59c981f --- /dev/null +++ b/src/utils/StatusBarManager.js @@ -0,0 +1,218 @@ +import { Capacitor } from '@capacitor/core' +import { StatusBar, Style } from '@capacitor/status-bar' +import { SafeArea } from 'capacitor-plugin-safe-area' + +/** + * StatusBarManager - A utility class to handle status bar configuration + * following Capacitor best practices and theme-aware styling + */ +class StatusBarManager { + constructor() { + this.isNativePlatform = Capacitor.isNativePlatform() + this.platform = Capacitor.getPlatform() + this.listeners = [] + this.currentTheme = 'light' + this.safeAreaApplied = false + } + + /** + * Initialize the status bar with proper configuration + * @param {string} initialTheme - The initial theme ('light' | 'dark' | 'system') + */ + async initialize(initialTheme = 'light') { + if (!this.isNativePlatform) { + console.log('StatusBarManager: Not running on native platform') + return + } + + try { + // Configure basic status bar settings - use overlay: true for precise control + await StatusBar.setOverlaysWebView({ overlay: false }) + await StatusBar.show() + + // Set initial theme + await this.setTheme(initialTheme) + + // Apply safe area insets + await this.applySafeAreaInsets() + + console.log('StatusBarManager: Initialized successfully') + } catch (error) { + console.error('StatusBarManager: Failed to initialize:', error) + } + } + + /** + * Set status bar style based on theme + * @param {string} theme - The theme ('light' | 'dark' | 'system') + */ + async setTheme(theme) { + if (!this.isNativePlatform) return + + this.currentTheme = theme + + try { + let style = Style.Light // Default to light content (dark status bar) + + if (theme === 'dark') { + style = Style.Dark // Dark content (light status bar) + } else if (theme === 'system') { + // For system theme, we need to detect the actual system preference + // Joy UI's useColorScheme will handle this, but we default to light + style = Style.Light + } + + await StatusBar.setStyle({ style }) + console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`) + } catch (error) { + console.error('StatusBarManager: Failed to set theme:', error) + } + } + + /** + * Apply safe area insets using CSS custom properties + * Components should use env() variables or CSS custom properties for proper safe area handling + */ + async applySafeAreaInsets() { + if (!this.isNativePlatform || this.safeAreaApplied) return + + try { + // Get safe area insets + const { insets } = await SafeArea.getSafeAreaInsets() + + // Apply CSS custom properties for safe area + this.applySafeAreaCSS(insets) + + this.safeAreaApplied = true + console.log('StatusBarManager: Safe area insets applied:', insets) + } catch (error) { + console.error( + 'StatusBarManager: Failed to apply safe area insets:', + error, + ) + } + } + + /** + * Apply safe area insets using CSS custom properties + * @param {Object} insets - The safe area insets + */ + applySafeAreaCSS(insets) { + const root = document.documentElement + + // Set CSS custom properties that can be used throughout the app + root.style.setProperty('--safe-area-inset-top', `${insets.top}px`) + root.style.setProperty('--safe-area-inset-right', `${insets.right}px`) + root.style.setProperty('--safe-area-inset-bottom', `${insets.bottom}px`) + root.style.setProperty('--safe-area-inset-left', `${insets.left}px`) + + // Note: We no longer apply padding directly to the body to avoid double + // application with component-level safe area handling. Components should + // use the CSS custom properties or the utility classes from safe-area.css + } + + /** + * Add a listener for theme changes + * @param {Function} callback - Function to call when theme changes + * @returns {Function} - Cleanup function to remove the listener + */ + addThemeChangeListener(callback) { + this.listeners.push(callback) + + // Return cleanup function + return () => { + const index = this.listeners.indexOf(callback) + if (index > -1) { + this.listeners.splice(index, 1) + } + } + } + + /** + * Notify all listeners of theme change + * @param {string} newTheme - The new theme + */ + notifyThemeChange(newTheme) { + this.listeners.forEach(callback => { + try { + callback(newTheme) + } catch (error) { + console.error( + 'StatusBarManager: Error in theme change listener:', + error, + ) + } + }) + } + + /** + * Update status bar based on resolved theme (after system detection) + * @param {string} resolvedTheme - The actual theme being used ('light' | 'dark') + */ + async updateResolvedTheme(resolvedTheme) { + if (!this.isNativePlatform) return + + try { + const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light + await StatusBar.setStyle({ style }) + console.log( + `StatusBarManager: Resolved theme updated to ${resolvedTheme}`, + ) + } catch (error) { + console.error('StatusBarManager: Failed to update resolved theme:', error) + } + } + + /** + * Hide the status bar + */ + async hide() { + if (!this.isNativePlatform) return + + try { + await StatusBar.hide() + } catch (error) { + console.error('StatusBarManager: Failed to hide status bar:', error) + } + } + + /** + * Show the status bar + */ + async show() { + if (!this.isNativePlatform) return + + try { + await StatusBar.show() + } catch (error) { + console.error('StatusBarManager: Failed to show status bar:', error) + } + } + + /** + * Get current status bar info + */ + async getInfo() { + if (!this.isNativePlatform) return null + + try { + return await StatusBar.getInfo() + } catch (error) { + console.error('StatusBarManager: Failed to get status bar info:', error) + return null + } + } + + /** + * Clean up all listeners and reset state + */ + cleanup() { + this.listeners = [] + this.safeAreaApplied = false + console.log('StatusBarManager: Cleaned up') + } +} + +// Create and export a singleton instance +const statusBarManager = new StatusBarManager() +export default statusBarManager diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx index 904f14b..86b2229 100644 --- a/src/views/Authorization/ForgotPasswordView.jsx +++ b/src/views/Authorization/ForgotPasswordView.jsx @@ -1,5 +1,5 @@ // create boilerplate for ResetPasswordView: -import LogoSVG from '@/assets/logo.svg' +import Logo from '../../Logo' import { Box, Button, @@ -80,16 +80,12 @@ const ForgotPasswordView = () => { @@ -104,102 +100,83 @@ const ForgotPasswordView = () => { padding: 2, borderRadius: '8px', boxShadow: 'md', - minHeight: '70vh', - justifyContent: 'space-between', - justifyItems: 'center', }} > - - logo - {/* */} - - Done - - tick - - - - {/* HERE */} - + + + + Done + tick + {resetStatusOk === null && ( -
-
- - Enter your email, and we'll send you a link to get into your - account. - - - { - if (e.key === 'Enter') { - e.preventDefault() - handleSubmit() - } - }} - /> - {emailError} - - - - - -
-
- )} - {resetStatusOk != null && ( <> - - - if there is an account associated with the email you entered, - you will receive an email with instructions on how to reset - your - - + + Enter your email, and we'll send you a link to get into your + account. + + + { + if (e.key === 'Enter') { + e.preventDefault() + handleSubmit() + } + }} + /> + {emailError} + + + + + + )} + {resetStatusOk != null && ( + <> + + If there is an account associated with the email you entered, + you will receive an email with instructions on how to reset + your password. + + + diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 15fd8f4..217ca19 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -1,3 +1,4 @@ +import { Browser } from '@capacitor/browser' import { Capacitor } from '@capacitor/core' import { Device } from '@capacitor/device' // import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth' @@ -251,19 +252,52 @@ const LoginView = () => { return randomState } - const handleAuthentikLogin = () => { + const handleAuthentikLogin = async () => { const authentikAuthorizeUrl = resource?.identity_provider?.auth_url + const state = generateRandomState() - const params = new URLSearchParams({ - response_type: 'code', - client_id: resource?.identity_provider?.client_id, - redirect_uri: `${window.location.origin}/auth/oauth2`, - scope: 'openid profile email', // Your scopes - state: generateRandomState(), - }) - console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`) + if (Capacitor.isNativePlatform()) { + // For mobile devices, use a custom URL scheme for the redirect + const redirectUri = 'donetick://auth/oauth2' + + const params = new URLSearchParams({ + response_type: 'code', + client_id: resource?.identity_provider?.client_id, + redirect_uri: redirectUri, + scope: 'openid profile email', + state: state, + }) - window.location.href = `${authentikAuthorizeUrl}?${params.toString()}` + const authUrl = `${authentikAuthorizeUrl}?${params.toString()}` + console.log('Opening OAuth in browser:', authUrl) + + try { + // Open OAuth flow in system browser + await Browser.open({ url: authUrl }) + + // Note: The OAuth callback will be handled by deep link handling + // You'll need to implement deep link handling to catch the redirect + // and extract the authorization code + } catch (error) { + console.error('Failed to open OAuth browser:', error) + showError({ + title: 'OAuth Error', + message: 'Failed to open authentication browser', + }) + } + } else { + // For web platforms, use the current approach + const params = new URLSearchParams({ + response_type: 'code', + client_id: resource?.identity_provider?.client_id, + redirect_uri: `${window.location.origin}/auth/oauth2`, + scope: 'openid profile email', + state: state, + }) + + console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`) + window.location.href = `${authentikAuthorizeUrl}?${params.toString()}` + } } return ( diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx index 58ba90d..3838601 100644 --- a/src/views/Authorization/Signup.jsx +++ b/src/views/Authorization/Signup.jsx @@ -11,6 +11,7 @@ import { } from '@mui/joy' import React from 'react' import { useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' import Logo from '../../Logo' import { useNotification } from '../../service/NotificationProvider' import { login, signUp } from '../../utils/Fetcher' @@ -19,6 +20,7 @@ const SignupView = () => { const [username, setUsername] = React.useState('') const [password, setPassword] = React.useState('') const Navigate = useNavigate() + const queryClient = useQueryClient() const [displayName, setDisplayName] = React.useState('') const [email, setEmail] = React.useState('') const [usernameError, setUsernameError] = React.useState('') @@ -32,11 +34,11 @@ const SignupView = () => { response.json().then(res => { localStorage.setItem('ca_token', res.token) localStorage.setItem('ca_expiration', res.expire) - setTimeout(() => { - // TODO: not sure if there is a race condition here - // but on first sign up it renavigates to login. - Navigate('/chores') - }, 500) + + // Invalidate user profile queries to ensure fresh data + queryClient.invalidateQueries(['userProfile']) + + Navigate('/chores') }) } else { console.log('Login failed', response) diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index e359f77..03668f1 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -117,7 +117,13 @@ const MyChores = () => { const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) useEffect(() => { ;(async () => { - if (!choresLoading && !membersLoading && userProfile) { + if ( + !choresLoading && + !membersLoading && + userProfile && + membersData?.res && + choresData?.res + ) { setPerformers(membersData.res) const sortedChores = choresData.res.sort(ChoreSorter) setChores(sortedChores) diff --git a/src/views/Chores/Sidepanel.jsx b/src/views/Chores/Sidepanel.jsx index f879c70..e374b28 100644 --- a/src/views/Chores/Sidepanel.jsx +++ b/src/views/Chores/Sidepanel.jsx @@ -3,13 +3,16 @@ import { useMediaQuery } from '@mui/material' import { useEffect, useState } from 'react' import { useChoresHistory } from '../../queries/ChoreQueries' import { ChoresGrouper } from '../../utils/Chores' +import { getSidepanelConfig } from '../../utils/SidepanelConfig' import CalendarView from '../components/CalendarView' import ActivitiesCard from './ActivitesCard' +import TasksByAssigneeCard from './TasksByAssigneeCard' import WelcomeCard from './WelcomeCard' const Sidepanel = ({ chores }) => { const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md')) const [dueDatePieChartData, setDueDatePieChartData] = useState([]) + const [sidepanelConfig, setSidepanelConfig] = useState([]) const { data: choresHistory, isChoresHistoryLoading, @@ -18,6 +21,18 @@ const Sidepanel = ({ chores }) => { useEffect(() => { setDueDatePieChartData(generateChoreDuePieChartData(chores)) + setSidepanelConfig(getSidepanelConfig()) + }, []) + + useEffect(() => { + const handleConfigChange = () => { + setSidepanelConfig(getSidepanelConfig()) + } + + window.addEventListener('sidepanelConfigChanged', handleConfigChange) + return () => { + window.removeEventListener('sidepanelConfigChanged', handleConfigChange) + } }, []) const generateChoreDuePieChartData = chores => { @@ -34,34 +49,60 @@ const Sidepanel = ({ chores }) => { .filter(item => item.value > 0) } + const renderCard = cardConfig => { + if (!cardConfig.enabled) return null + + switch (cardConfig.id) { + case 'welcome': + return + case 'assignees': + return + case 'calendar': + return ( + + + + + + ) + case 'activities': + return ( + + ) + + default: + return null + } + } + if (!isLargeScreen) { return null } - return ( - - - - - - - - - - ) + + const sortedCards = [...sidepanelConfig].sort((a, b) => a.order - b.order) + + return {sortedCards.map(cardConfig => renderCard(cardConfig))} } export default Sidepanel diff --git a/src/views/Chores/TasksByAssigneeCard.jsx b/src/views/Chores/TasksByAssigneeCard.jsx new file mode 100644 index 0000000..787992b --- /dev/null +++ b/src/views/Chores/TasksByAssigneeCard.jsx @@ -0,0 +1,398 @@ +import { BarChart, Person } from '@mui/icons-material' +import { Avatar, Box, Sheet, Typography } from '@mui/joy' +import { useEffect, useState } from 'react' +import { useCircleMembers } from '../../queries/UserQueries' +import { TASK_COLOR } from '../../utils/Colors' +import { resolvePhotoURL } from '../../utils/Helpers' + +const TasksByAssigneeCard = ({ chores = [] }) => { + const [assigneeData, setAssigneeData] = useState([]) + const { data: circleMembersData, isLoading: isCircleMembersLoading } = + useCircleMembers() + + useEffect(() => { + if ( + !isCircleMembersLoading && + circleMembersData?.res && + chores.length > 0 + ) { + const members = circleMembersData.res + const data = processTasksByAssignee(chores, members) + setAssigneeData(data) + } + }, [chores, circleMembersData, isCircleMembersLoading]) + + const processTasksByAssignee = (chores, members) => { + const assigneeStats = {} + + // Initialize stats for all members + members.forEach(member => { + assigneeStats[member.userId] = { + id: member.userId, + name: member.displayName || member.name, + image: member.image, + inProgress: 0, + overdue: 0, + scheduled: 0, + pendingReview: 0, + total: 0, + } + }) + + // Count tasks by status for each assignee + chores.forEach(chore => { + if (chore.assignedTo && assigneeStats[chore.assignedTo]) { + const assignee = assigneeStats[chore.assignedTo] + assignee.total++ + + // Map chore status to our categories based on your system + if (chore.status === 3) { + // Pending approval/review + assignee.pendingReview++ + } else if (chore.status === 1 || chore.status === 2) { + // In progress (started or paused) + assignee.inProgress++ + } else if ( + chore.nextDueDate && + new Date(chore.nextDueDate) < new Date() + ) { + // Overdue - past due date + assignee.overdue++ + } else { + // Scheduled/planned - future due date or no due date + assignee.scheduled++ + } + } + }) + + // Filter out members with no tasks and sort by total tasks + return Object.values(assigneeStats) + .filter(assignee => assignee.total > 0) + .sort((a, b) => b.total - a.total) + } + + const getStatusColor = status => { + switch (status) { + case 'inProgress': + return TASK_COLOR.IN_PROGRESS + case 'overdue': + return TASK_COLOR.OVERDUE + case 'scheduled': + return TASK_COLOR.COMPLETED + case 'pendingReview': + return TASK_COLOR.PENDING_REVIEW + default: + return TASK_COLOR.DEFAULT + } + } + + const maxTasks = Math.max(...assigneeData.map(a => a.total), 1) + + if (isCircleMembersLoading) { + return ( + + + Loading tasks by assignee... + + + ) + } + + if (assigneeData.length === 0) { + return ( + + + + No assigned tasks found + + + ) + } + + return ( + + {/* Header */} + + + + Tasks by Assignee + + + + {/* Legend */} + + {[ + { + key: 'inProgress', + label: 'In Progress', + color: getStatusColor('inProgress'), + }, + { + key: 'overdue', + label: 'Overdue', + color: getStatusColor('overdue'), + }, + { + key: 'scheduled', + label: 'Scheduled', + color: getStatusColor('scheduled'), + }, + { + key: 'pendingReview', + label: 'Pending Review', + color: getStatusColor('pendingReview'), + }, + ].map(status => ( + + + + {status.label} + + + ))} + + + {/* Chart Container */} + + {/* Chart */} + + {assigneeData.slice(0, 6).map((assignee, index) => { + const barHeight = Math.max((assignee.total / maxTasks) * 140, 8) + + return ( + + {/* Avatar */} + + {assignee.name?.charAt(0) || } + + + {/* Stacked bars */} + + {/* Pending Review - bottom */} + {assignee.pendingReview > 0 && ( + + )} + + {/* Scheduled */} + {assignee.scheduled > 0 && ( + + )} + + {/* In Progress */} + {assignee.inProgress > 0 && ( + + )} + + {/* Overdue - top */} + {assignee.overdue > 0 && ( + + )} + + + {/* Name */} + + + {assignee.name} + + + + ) + })} + + + {/* Y-axis labels */} + + {[0, 20, 40, 60, 80, 100].map((value, index) => { + const yPosition = (value / 100) * 140 + return ( + + {Math.round((value / 100) * maxTasks)} + + ) + })} + + + + ) +} + +export default TasksByAssigneeCard diff --git a/src/views/Settings/MFASettings.jsx b/src/views/Settings/MFASettings.jsx index 1e3c560..355e596 100644 --- a/src/views/Settings/MFASettings.jsx +++ b/src/views/Settings/MFASettings.jsx @@ -210,7 +210,7 @@ const MFASettings = () => { } return ( -
+
Multi-Factor Authentication diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx index fd37852..b7cbedf 100644 --- a/src/views/Settings/Settings.jsx +++ b/src/views/Settings/Settings.jsx @@ -1,4 +1,5 @@ import { Capacitor } from '@capacitor/core' +import { Refresh } from '@mui/icons-material' import { Box, Button, @@ -18,6 +19,7 @@ import { import { Purchases } from '@revenuecat/purchases-capacitor' import moment from 'moment' import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' import RealTimeSettings from '../../components/RealTimeSettings' import SubscriptionModal from '../../components/SubscriptionModal' import Logo from '../../Logo' @@ -45,12 +47,14 @@ import APITokenSettings from './APITokenSettings' import MFASettings from './MFASettings' import NotificationSetting from './NotificationSetting' import ProfileSettings from './ProfileSettings' +import SidepanelSettings from './SidepanelSettings' import StorageSettings from './StorageSettings' import ThemeToggle from './ThemeToggle' const Settings = () => { const { data: userProfile } = useUserProfile() const { showNotification } = useNotification() + const navigate = useNavigate() const [userCircles, setUserCircles] = useState([]) const [circleMemberRequests, setCircleMemberRequests] = useState([]) @@ -59,6 +63,8 @@ const Settings = () => { const [webhookURL, setWebhookURL] = useState(null) const [webhookError, setWebhookError] = useState(null) const [isAdmin, setIsAdmin] = useState(false) + const [lastRefresh, setLastRefresh] = useState(null) + const [isRefreshing, setIsRefreshing] = useState(false) const [changePasswordModal, setChangePasswordModal] = useState(false) const [subscriptionModal, setSubscriptionModal] = useState(false) @@ -88,6 +94,23 @@ const Settings = () => { }, }) } + const refreshMemberRequests = async () => { + setIsRefreshing(true) + try { + const resp = await GetCircleMemberRequests() + const data = await resp.json() + setCircleMemberRequests(data.res ? data.res : []) + setLastRefresh(new Date()) + } catch (error) { + showNotification({ + type: 'error', + message: 'Failed to refresh member requests', + }) + } finally { + setIsRefreshing(false) + } + } + useEffect(() => { GetUserCircle().then(resp => { resp.json().then(data => { @@ -98,6 +121,7 @@ const Settings = () => { GetCircleMemberRequests().then(resp => { resp.json().then(data => { setCircleMemberRequests(data.res ? data.res : []) + setLastRefresh(new Date()) }) }) GetAllCircleMembers().then(data => { @@ -116,15 +140,35 @@ const Settings = () => { }, [circleMembers, userProfile]) useEffect(() => { - const hash = window.location.hash - if (hash) { - const sharingSection = document.getElementById( - window.location.hash.slice(1), - ) - if (sharingSection) { - sharingSection.scrollIntoView({ behavior: 'smooth' }) + const handleHashChange = () => { + const hash = window.location.hash + if (hash) { + // Small delay to ensure the component is fully rendered before scrolling + setTimeout(() => { + const section = document.getElementById(hash.slice(1)) + if (section) { + // Get the element position and scroll with some offset for the title + const elementPosition = section.offsetTop + const offsetPosition = elementPosition - 20 // 20px padding above the title + + window.scrollTo({ + top: offsetPosition, + behavior: 'instant', // Use 'smooth' for smooth scrolling + }) + } + }, 500) } } + + // Handle initial hash on mount + handleHashChange() + + // Listen for hash changes + window.addEventListener('hashchange', handleHashChange) + + return () => { + window.removeEventListener('hashchange', handleHashChange) + } }, []) const getSubscriptionDetails = () => { @@ -172,10 +216,11 @@ const Settings = () => { if (!userProfile) { return } + return ( -
+
Circle settings @@ -406,9 +451,35 @@ const Settings = () => { ))} - {circleMemberRequests.length > 0 && ( + Circle Member Requests - )} + + {lastRefresh && ( + + Last updated: {moment(lastRefresh).format('MMM DD, HH:mm')} + + )} + + + + {circleMemberRequests.map(request => ( @@ -729,7 +800,18 @@ const Settings = () => { -
+
+ Sidepanel Customization + + + Customize the layout and visibility of cards in the sidepanel. the + section only available on large screen devices such as tablets and + desktops.. + + +
+ +
Theme preferences diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx new file mode 100644 index 0000000..d099a2a --- /dev/null +++ b/src/views/Settings/SettingsOverview.jsx @@ -0,0 +1,300 @@ +import { + AccountCircle, + Api, + Circle, + Notifications, + Palette, + Person, + Security, + Star, + Storage, + ViewSidebar, +} from '@mui/icons-material' +import { + Avatar, + Box, + Button, + Card, + CardContent, + Container, + Grid, + Typography, +} from '@mui/joy' +import { useNavigate } from 'react-router-dom' +import { useUserProfile } from '../../queries/UserQueries' +import { isPlusAccount } from '../../utils/Helpers' + +const SettingsOverview = () => { + const navigate = useNavigate() + const { data: userProfile } = useUserProfile() + + const settingsCards = [ + { + id: 'profile', + title: 'Profile Settings', + description: + 'Update your profile information, photo, display name, and timezone preferences.', + icon: , + color: 'primary', + }, + { + id: 'circle', + title: 'Circle Settings', + description: + 'Manage your circle, invite members, handle join requests, and configure webhooks.', + icon: , + color: 'success', + }, + { + id: 'account', + title: 'Account Settings', + description: + 'Manage your subscription, change password, and account deletion options.', + icon: , + color: 'warning', + }, + { + id: 'notifications', + title: 'Notifications', + description: + 'Configure push notifications, email alerts, and notification targets for tasks.', + icon: , + color: 'info', + }, + { + id: 'mfa', + title: 'Multi-Factor Authentication', + description: + 'Add an extra layer of security with MFA using authenticator apps.', + icon: , + color: 'danger', + }, + { + id: 'apitokens', + title: 'API Tokens', + description: + 'Generate and manage access tokens for third-party integrations and API access.', + icon: , + color: 'neutral', + }, + { + id: 'storage', + title: 'Storage Settings', + description: + 'Backup and restore your data, manage local storage and sync preferences.', + icon: , + color: 'primary', + }, + { + id: 'sidepanel', + title: 'Sidepanel Customization', + description: + 'Customize the layout and visibility of cards in the sidepanel interface.', + icon: , + color: 'success', + }, + { + id: 'theme', + title: 'Theme Preferences', + description: + 'Choose your preferred theme and configure dark/light mode settings.', + icon: , + color: 'warning', + }, + ] + + const handleCardClick = settingId => { + navigate(`/settings/detailed#${settingId}`) + } + + return ( + + + + Settings + + + Customize your experience and manage your account preferences + + + + {/* Upgrade Card - Only show if user is not a Plus member */} + {userProfile && !isPlusAccount(userProfile) && ( + + + navigate('/settings/detailed#account')} + > + + + + + + + + + Upgrade to Plus + + + Unlock powerful features to enhance your productivity + + + • Rich text descriptions + • Task notifications + • API integrations + • Advanced automation + + + + + + + + + + )} + + + {settingsCards.map(setting => ( + + handleCardClick(setting.id)} + > + + + + {setting.icon} + + + {setting.title} + + + {setting.description} + + + + + + ))} + + + ) +} + +export default SettingsOverview diff --git a/src/views/Settings/SidepanelSettings.jsx b/src/views/Settings/SidepanelSettings.jsx new file mode 100644 index 0000000..80b72ba --- /dev/null +++ b/src/views/Settings/SidepanelSettings.jsx @@ -0,0 +1,258 @@ +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' +import { + CalendarMonth, + DragIndicator, + History, + Person, + Visibility, + VisibilityOff, + WavingHand, +} from '@mui/icons-material' +import { + Box, + Button, + Card, + Checkbox, + FormControl, + FormHelperText, + IconButton, + List, + ListItem, + ListItemContent, + ListItemDecorator, + Typography, +} from '@mui/joy' +import { useEffect, useState } from 'react' + +const DEFAULT_SIDEPANEL_CONFIG = [ + { + id: 'welcome', + name: 'Welcome Card', + description: 'Shows greeting and quick stats', + iconName: 'WavingHand', + enabled: true, + order: 0, + }, + { + id: 'assignees', + name: 'Tasks by Assignee', + description: 'Groups tasks by who they are assigned to', + iconName: 'Person', + enabled: true, + order: 1, + }, + { + id: 'calendar', + name: 'Calendar View', + description: 'Shows tasks in a calendar format', + iconName: 'CalendarMonth', + enabled: true, + order: 2, + }, + { + id: 'activities', + name: 'Recent Activities', + description: 'Shows recent task completions and activities', + iconName: 'History', + enabled: true, + order: 3, + }, +] + +const SidepanelSettings = () => { + const [config, setConfig] = useState(DEFAULT_SIDEPANEL_CONFIG) + + const getIcon = iconName => { + switch (iconName) { + case 'WavingHand': + return + case 'Person': + return + case 'CalendarMonth': + return + case 'History': + return + default: + return + } + } + + useEffect(() => { + const saved = localStorage.getItem('sidepanelConfig') + if (saved) { + try { + const parsed = JSON.parse(saved) + setConfig(parsed) + } catch (error) { + console.error('Error parsing sidepanel config:', error) + } + } + }, []) + + const saveConfig = newConfig => { + setConfig(newConfig) + localStorage.setItem('sidepanelConfig', JSON.stringify(newConfig)) + window.dispatchEvent(new Event('sidepanelConfigChanged')) + } + + const handleToggleEnabled = (id, enabled) => { + const newConfig = config.map(item => + item.id === id ? { ...item, enabled } : item, + ) + saveConfig(newConfig) + } + + const handleDragEnd = result => { + if (!result.destination) return + + const newConfig = Array.from(config) + const [reorderedItem] = newConfig.splice(result.source.index, 1) + newConfig.splice(result.destination.index, 0, reorderedItem) + + const updatedConfig = newConfig.map((item, index) => ({ + ...item, + order: index, + })) + + saveConfig(updatedConfig) + } + + const resetToDefaults = () => { + saveConfig(DEFAULT_SIDEPANEL_CONFIG) + } + + return ( + + + Sidepanel Settings + + + Customize which cards appear in the sidepanel and their order. Drag and + drop to reorder, or toggle visibility for each card. + + + + + {provided => ( + + {config.map((item, index) => ( + + {(provided, snapshot) => ( + + + + + + + + + + {getIcon(item.iconName)} + + + + + + {item.name} + + + - {item.description} + + + + + + + handleToggleEnabled(item.id, e.target.checked) + } + overlay + variant='plain' + size='lg' + checkedIcon={} + uncheckedIcon={} + /> + + + + )} + + ))} + {provided.placeholder} + + )} + + + + + + + This will restore all cards to their default visibility and order. + + + + ) +} + +export default SidepanelSettings diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index ea257f2..fc2e741 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -25,6 +25,7 @@ import { import { useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { version } from '../../../package.json' +import UserProfileAvatar from '../../components/UserProfileAvatar' import ThemeToggleButton from '../Settings/ThemeToggleButton' import NavBarLink from './NavBarLink' const links = [ @@ -113,9 +114,9 @@ const NavBar = () => { return (