Enhance Settings, Things History, User Activities, and User Points views

- Integrated RevenueCat for subscription management in Settings.
- Added subscription and user deletion modals in Settings.
- Improved analytics display in Things History with update frequency and trend calculations.
- Enhanced User Activities timeline with a more structured layout.
- Revamped User Points section with a leaderboard and detailed points analytics.
- Introduced responsive design elements and improved user experience across various components.
- Add permission for Camera to support uploading photo via camera
This commit is contained in:
Mo Tarbin
2025-08-04 22:25:30 -04:00
parent 72ae2ec5c4
commit c9178db87d
18 changed files with 1806 additions and 301 deletions

View File

@@ -9,15 +9,19 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-app')
implementation project(':capacitor-device')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-status-bar')
implementation project(':capgo-capacitor-social-login')
implementation project(':revenuecat-purchases-capacitor')
implementation project(':revenuecat-purchases-capacitor-ui')
implementation project(':capacitor-plugin-safe-area')
implementation "com.android.billingclient:billing:7.1.1"
}

View File

@@ -30,6 +30,10 @@
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -2,6 +2,9 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
@@ -11,6 +14,9 @@ project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/d
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
@@ -23,5 +29,11 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
include ':capgo-capacitor-social-login'
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')
include ':revenuecat-purchases-capacitor'
project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor/android')
include ':revenuecat-purchases-capacitor-ui'
project(':revenuecat-purchases-capacitor-ui').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor-ui/android')
include ':capacitor-plugin-safe-area'
project(':capacitor-plugin-safe-area').projectDir = new File('../node_modules/capacitor-plugin-safe-area/android')

55
ios/App/App/Info.plist Normal file
View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>DoneTick</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<!-- Camera permissions -->
<key>NSCameraUsageDescription</key>
<string>This app needs access to camera to take photos for your profile picture.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photo library to select photos for your profile picture.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app needs access to save photos to your photo library.</string>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
platform :ios, '15.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -11,14 +11,19 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device'
pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapgoCapacitorSocialLogin', :path => '../../node_modules/@capgo/capacitor-social-login'
pod 'RevenuecatPurchasesCapacitor', :path => '../../node_modules/@revenuecat/purchases-capacitor'
pod 'RevenuecatPurchasesCapacitorUi', :path => '../../node_modules/@revenuecat/purchases-capacitor-ui'
pod 'CapacitorPluginSafeArea', :path => '../../node_modules/capacitor-plugin-safe-area'
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
end
target 'App' do

View File

@@ -10,15 +10,15 @@ PODS:
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- Capacitor (7.2.0):
- Capacitor (7.4.2):
- CapacitorCordova
- CapacitorApp (7.0.1):
- Capacitor
- CapacitorCommunitySqlite (7.0.0):
- CapacitorCommunitySqlite (7.0.1):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (7.2.0)
- CapacitorCordova (7.4.2)
- CapacitorDevice (7.0.1):
- Capacitor
- CapacitorLocalNotifications (7.0.1):
@@ -39,6 +39,8 @@ PODS:
- FBSDKCoreKit (= 17.4.0)
- FBSDKLoginKit (= 17.4.0)
- GoogleSignIn (~> 8.0.0)
- CordovaPlugins (7.4.2):
- CapacitorCordova
- FBAEMKit (17.4.0):
- FBSDKCoreKit_Basics (= 17.4.0)
- FBSDKCoreKit (17.4.0):
@@ -66,6 +68,20 @@ PODS:
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher/Core (3.5.0)
- PromisesObjC (2.4.0)
- PurchasesHybridCommon (16.1.0):
- RevenueCat (= 5.33.1)
- PurchasesHybridCommonUI (16.1.0):
- PurchasesHybridCommon (= 16.1.0)
- RevenueCatUI (= 5.33.1)
- RevenueCat (5.33.1)
- RevenuecatPurchasesCapacitor (11.1.0):
- Capacitor
- PurchasesHybridCommon (= 16.1.0)
- RevenuecatPurchasesCapacitorUi (11.1.0):
- Capacitor
- PurchasesHybridCommonUI (= 16.1.0)
- RevenueCatUI (5.33.1):
- RevenueCat (= 5.33.1)
- SQLCipher (4.7.0):
- SQLCipher/standard (= 4.7.0)
- SQLCipher/common (4.7.0)
@@ -76,6 +92,7 @@ PODS:
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorDevice (from `../../node_modules/@capacitor/device`)"
- "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)"
@@ -85,6 +102,9 @@ DEPENDENCIES:
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- "CapgoCapacitorSocialLogin (from `../../node_modules/@capgo/capacitor-social-login`)"
- CordovaPlugins (from `../capacitor-cordova-ios-plugins`)
- "RevenuecatPurchasesCapacitor (from `../../node_modules/@revenuecat/purchases-capacitor`)"
- "RevenuecatPurchasesCapacitorUi (from `../../node_modules/@revenuecat/purchases-capacitor-ui`)"
SPEC REPOS:
trunk:
@@ -100,6 +120,10 @@ SPEC REPOS:
- GTMAppAuth
- GTMSessionFetcher
- PromisesObjC
- PurchasesHybridCommon
- PurchasesHybridCommonUI
- RevenueCat
- RevenueCatUI
- SQLCipher
- ZIPFoundation
@@ -108,6 +132,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios"
CapacitorApp:
:path: "../../node_modules/@capacitor/app"
CapacitorCommunitySqlite:
:path: "../../node_modules/@capacitor-community/sqlite"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorDevice:
@@ -126,15 +152,21 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/status-bar"
CapgoCapacitorSocialLogin:
:path: "../../node_modules/@capgo/capacitor-social-login"
CordovaPlugins:
:path: "../capacitor-cordova-ios-plugins"
RevenuecatPurchasesCapacitor:
:path: "../../node_modules/@revenuecat/purchases-capacitor"
RevenuecatPurchasesCapacitorUi:
:path: "../../node_modules/@revenuecat/purchases-capacitor-ui"
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
Capacitor: 9d9e481b79ffaeacaf7a85d6a11adec32bd33b59
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
CapacitorCommunitySqlite: b11e556cf5d149f9e0b6fbbb47959a6cd718c3d0
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorCommunitySqlite: 8b2c6bab33e3519280811d481f8bd0fa90343e1b
CapacitorCordova: 5e58d04631bc5094894ac106e2bf1da18a9e6151
CapacitorDevice: c6f6d587dd310527f8a48bf09c4e7b4a4cf14329
CapacitorLocalNotifications: c2212755b33d2513b8bb325096ce3a64d4039ae3
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
@@ -143,6 +175,7 @@ SPEC CHECKSUMS:
CapacitorPushNotifications: 6a2794788c583dd89215f1805ca4bced1b13dbdf
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
CapgoCapacitorSocialLogin: 0daa087f32775c656fbfba09b8eae7c089b85ce2
CordovaPlugins: b2618e17f8dd580d2354710b7b52b0af9d6f03f2
FBAEMKit: 58cb5f302cdd715a56d4c1d0dfdd2e423ac1421a
FBSDKCoreKit: 94d7461d0cecf441b1ba7c41acfff41daa8ccd41
FBSDKCoreKit_Basics: 151b43db8b834d3f0e02f95d36a44ffd36265e45
@@ -152,9 +185,15 @@ SPEC CHECKSUMS:
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PurchasesHybridCommon: acbd336f8248da5599893ecf9ac32c561a2fd446
PurchasesHybridCommonUI: cf20e6ac6f148385bdb6a15db38c4c0c7377f480
RevenueCat: b0ed01125b05a45b8264a2951ad68acb61942038
RevenuecatPurchasesCapacitor: 554b5d4711cac8df01feed6333b075296b5d9082
RevenuecatPurchasesCapacitorUi: 7863f7e39e940d937925b085a67369feecb048e4
RevenueCatUI: e6fad6a8bec67d910da78cd171328f1104afea77
SQLCipher: ba9d0076041ed767c5bd3d3f77098318d04a403c
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: 54ec5ec72c1c5e3e8d03bd96cc1ec0deaf577d9e
PODFILE CHECKSUM: 8a9fb4ff57aa677972b999357aa5abe97d1f2b30
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

View File

@@ -51,12 +51,17 @@
"@mui/joy": "^5.0.0-beta.20",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
"@revenuecat/purchases-capacitor": "^11.1.0",
"@revenuecat/purchases-capacitor-ui": "^11.1.0",
"@rollup/rollup-darwin-arm64": "^4.46.1",
"@swc/core": "^1.12.5",
"@swc/core-darwin-arm64": "^1.13.3",
"@tanstack/react-query": "^5.17.0",
"aos": "^2.3.4",
"browser-image-compression": "^2.0.2",
"capacitor-plugin-safe-area": "^4.0.0",
"chrono-node": "^2.7.7",
"cordova-plugin-purchase": "^13.12.1",
"dotenv": "^16.4.5",
"esm": "^3.2.25",
"event-source-polyfill": "^1.0.31",

View File

@@ -0,0 +1,259 @@
import { Check, Star } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
Divider,
Modal,
ModalDialog,
Radio,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
const SubscriptionModal = ({ open, onClose }) => {
const [selectedPlan, setSelectedPlan] = useState('yearly')
const [isLoading, setIsLoading] = useState(false)
const { showError } = useNotification()
const plans = {
yearly: {
price: '$39.00',
period: 'year',
total: '$39.00/year',
// savings: 'Save $20.88',
// popular: true,
},
// monthly: {
// price: '$4.99',
// period: 'month',
// total: '$4.99/month',
// savings: null,
// },
}
const features = [
'Task notifications and reminders',
'Rich text descriptions with images uploads',
'Thing-based task triggers',
'API tokens for integrations',
'Image uploads in descriptions',
'Advanced task automation',
// 'Unlimited task history',
// 'Unlimited things history',
]
const handleSubscribe = async () => {
setIsLoading(true)
try {
// Call the backend with the selected plan
const response = await GetSubscriptionSession()
if (!response.ok) {
throw new Error('Failed to create subscription session')
}
const data = await response.json()
// Redirect to Stripe
if (data.sessionURL) {
window.location.href = data.sessionURL
} else {
throw new Error('No session URL received')
}
} catch (error) {
console.error('Subscription error:', error)
showError({
title: 'Subscription Error',
message: 'Failed to start subscription process. Please try again.',
})
} finally {
setIsLoading(false)
}
}
return (
<Modal open={open} onClose={onClose}>
<ModalDialog
layout='center'
sx={{
width: 600,
maxWidth: '95vw',
maxHeight: '95vh',
overflow: 'auto',
p: 0,
}}
>
<Box sx={{ p: 4 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography level='h3' sx={{ mb: 1 }}>
Upgrade to Plus
</Typography>
</Box>
{/* Features List */}
<Box sx={{ mb: 2 }}>
<Typography level='title-lg' sx={{ mb: 2 }}>
What&apos;s included:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{features.map((feature, index) => (
<Box
key={index}
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
>
<Check color='success' sx={{ fontSize: 20 }} />
<Typography level='body-md'>{feature}</Typography>
</Box>
))}
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Plan Selection */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
>
{Object.entries(plans).map(([key, plan]) => (
<Card
key={key}
color={selectedPlan === key ? 'primary' : 'neutral'}
onClick={() => setSelectedPlan(key)}
sx={{
width: '100%',
minHeight: 48,
maxHeight: 64,
cursor: 'pointer',
transition: 'all 0.2s',
mb: 0.2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 1.2,
position: 'relative',
overflow: 'visible',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
justifyContent: 'flex-start',
width: '100%',
}}
>
<Radio
checked={selectedPlan === key}
onChange={() => setSelectedPlan(key)}
value={key}
name='subscription-plan'
color='primary'
sx={{ mr: 1 }}
/>
<Typography level='body-md' sx={{ fontWeight: 600 }}>
{key.charAt(0).toUpperCase() + key.slice(1)}
</Typography>
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
{plan.price}
<span style={{ color: '#888', fontWeight: 400 }}>
{' '}
/ {plan.period}
</span>
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
position: 'absolute',
right: 16,
top: -18,
}}
>
{plan.popular && (
<Chip
variant='solid'
color='warning'
size='sm'
startDecorator={<Star />}
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
Most Popular
</Chip>
)}
{plan.savings && (
<Chip
variant='soft'
color='success'
size='sm'
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
{plan.savings}
</Chip>
)}
</Box>
</Card>
))}
</Box>
{/* Action Buttons */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
>
<Button
variant='solid'
color='primary'
onClick={handleSubscribe}
loading={isLoading}
fullWidth
size='lg'
sx={{ mb: 1 }}
>
Subscribe
</Button>
<Button
variant='plain'
onClick={onClose}
disabled={isLoading}
fullWidth
>
Cancel
</Button>
</Box>
{/* Footer */}
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 3 }}
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
</Typography>
</Box>
</ModalDialog>
</Modal>
)
}
export default SubscriptionModal

View File

@@ -27,7 +27,6 @@ const LABEL_COLORS = [
]
export const COLORS = {
white: '#FFFFFF',
salmon: '#ff7961',
teal: '#26a69a',
skyBlue: '#80d8ff',
@@ -52,6 +51,7 @@ export const COLORS = {
blush: '#f8bbd0',
ash: '#90a4ae',
sand: '#d7ccc8',
white: '#FFFFFF',
}
export const TASK_COLOR = {

View File

@@ -596,14 +596,61 @@ const ClearChoreTimer = choreId => {
})
}
const CheckUserDeletion = (password) => {
return Fetch(`/users/delete/check`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
password,
}),
})
}
const DeleteUser = (password, confirmation, transferOptions = []) => {
return Fetch(`/users/delete`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({
password,
confirmation,
transferOptions,
}),
})
}
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
return Fetch(`/backup/create`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
encryption_key: encryptionKey,
include_assets: includeAssets,
backup_name: backupName,
}),
})
}
const RestoreBackup = (encryptionKey, backupData) => {
return Fetch(`/backup/restore`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
encryption_key: encryptionKey,
backup_data: backupData,
}),
})
}
export {
AcceptCircleMemberRequest,
ArchiveChore,
CancelSubscription,
ChangePassword,
CheckUserDeletion,
ClearChoreTimer,
CompleteSubTask,
ConfirmMFA,
CreateBackup,
CreateChore,
CreateLabel,
CreateLongLiveToken,
@@ -615,6 +662,7 @@ export {
DeleteLongLiveToken,
DeleteThing,
DeleteTimeSession,
DeleteUser,
DisableMFA,
GetAllCircleMembers,
GetAllUsers,
@@ -648,6 +696,7 @@ export {
RegenerateBackupCodes,
ResetChoreTimer,
ResetPassword,
RestoreBackup,
SaveChore,
SaveThing,
SetupMFA,

View File

@@ -1,7 +1,9 @@
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
import { SocialLogin } from '@capgo/capacitor-social-login'
import { Settings } from '@mui/icons-material'
import AppleIcon from '@mui/icons-material/Apple'
import GoogleIcon from '@mui/icons-material/Google'
import {
Avatar,
@@ -35,6 +37,7 @@ const LoginView = () => {
const [password, setPassword] = useState('')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
const { data: resource } = useResource()
const { showError } = useNotification()
const Navigate = useNavigate()
@@ -47,6 +50,21 @@ const LoginView = () => {
mode: 'online', // replaces grantOfflineAccess
},
})
// Check if Apple Sign In is supported (iOS 13+)
if (Capacitor.isNativePlatform()) {
try {
const deviceInfo = await Device.getInfo()
if (deviceInfo.platform === 'ios') {
const majorVersion = parseInt(deviceInfo.osVersion.split('.')[0])
setIsAppleSignInSupported(majorVersion >= 13)
}
} catch (error) {
console.log(
'Could not determine device info for Apple Sign In support',
)
}
}
}
initializeSocialLogin()
}, [])
@@ -127,6 +145,12 @@ const LoginView = () => {
} else if (data['accessToken']) {
// data["accessToken"] is for Google Capacitor
return data['accessToken']['token']
} else if (data['response'] && data['response']['id_token']) {
// Apple Sign In returns id_token in response
return data['response']['id_token']
} else if (data['id_token']) {
// Direct id_token for Apple
return data['id_token']
}
}
@@ -165,9 +189,10 @@ const LoginView = () => {
})
}
return response.json().then(() => {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: 'Google Login Failed',
message: "Couldn't log in with Google, please try again",
title: `${providerName} Login Failed`,
message: `Couldn't log in with ${providerName}, please try again`,
})
})
})
@@ -447,8 +472,50 @@ const LoginView = () => {
</div>
</Button>
</LoginSocialGoogle>
{/* <Button
fullWidth
variant='soft'
color='neutral'
size='lg'
sx={{
mt: 1,
mb: 1,
backgroundColor: 'black',
color: 'white',
'&:hover': {
backgroundColor: '#333',
},
}}
onClick={() => {
SocialLogin.login({
provider: 'apple',
options: {
scopes: ['email', 'name'],
},
})
.then(user => {
console.log('Apple user', user)
loggedWithProvider('apple', user)
})
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
message:
"Couldn't log in with Apple, please try again",
})
})
}}
>
<div className='flex gap-2'>
<AppleIcon />
Continue with Apple
</div>
</Button> */}
</Box>
)}
{Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<Button
@@ -457,16 +524,6 @@ const LoginView = () => {
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={() => {
// GoogleAuth.initialize({
// clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
// scopes: ['profile', 'email', 'openid'],
// grantOfflineAccess: true,
// })
// GoogleAuth.signIn().then(user => {
// console.log('Google user', user)
// loggedWithProvider('google', user.authentication)
// })
SocialLogin.login({
provider: 'google',
options: { scopes: ['profile', 'email', 'openid'] },
@@ -481,6 +538,45 @@ const LoginView = () => {
Continue with Google
</div>
</Button>
{/* Apple Sign In Button for Native Platforms */}
{isAppleSignInSupported && (
<Button
fullWidth
variant='soft'
color='neutral'
size='lg'
sx={{
mb: 1,
}}
onClick={() => {
SocialLogin.login({
provider: 'apple',
options: {
scopes: ['email', 'name'],
state: 'random_string',
},
})
.then(user => {
console.log('Apple user', user)
loggedWithProvider('apple', user)
})
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
message:
"Couldn't log in with Apple, please try again",
})
})
}}
>
<div className='flex gap-2'>
<AppleIcon />
Continue with Apple
</div>
</Button>
)}
</Box>
)}
</>

View File

@@ -694,23 +694,17 @@ const ChoreView = () => {
/>
</FormControl>
{note !== null && (
<Input
fullWidth
multiline
label='Additional Notes'
placeholder='Add any additional notes here...'
value={note || ''}
onChange={e => {
if (e.target.value.trim() === '') {
setNote(null)
return
}
setNote(e.target.value)
}}
sx={{
mb: 1,
}}
/>
<Box sx={{ mb: 1 }}>
<Typography level='body-sm' sx={{ mb: 1 }}>
Additional Notes:
</Typography>
<RichTextEditor
value={note || ''}
onChange={setNote}
entityType={'chore_completion_note'}
placeholder='Add a note about the completion...'
/>
</Box>
)}
<FormControl size='sm'>

View File

@@ -1,20 +1,27 @@
import { Checklist, EventBusy, Group, Timelapse } from '@mui/icons-material'
import {
Avatar,
Analytics,
Checklist,
EventBusy,
Group,
Star,
Timelapse,
TrendingUp,
} from '@mui/icons-material'
import {
Box,
Button,
Chip,
Card,
CardContent,
Container,
Grid,
List,
ListItem,
ListItemContent,
Sheet,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { LoadingScreen, SmoothCard } from '../../components/animations'
import { LoadingScreen } from '../../components/animations'
import {
DeleteChoreHistory,
GetAllCircleMembers,
@@ -102,39 +109,38 @@ const ChoreHistory = () => {
subtext: `${histories.length} times`,
},
{
icon: <Timelapse />,
text: 'Usually Within',
icon: <TrendingUp />,
text: 'Average Timing',
subtext: moment.duration(averageDelayMoment).isValid()
? moment.duration(averageDelayMoment).humanize()
: '--',
: 'On time',
},
{
icon: <Timelapse />,
text: 'Maximum Delay',
subtext: moment.duration(maxDelayMoment).isValid()
? moment.duration(maxDelayMoment).humanize()
: '--',
: 'Never late',
},
{
icon: <Avatar />,
text: ' Completed Most',
icon: <Star />,
text: 'Top Performer',
subtext: `${
performers.find(p => p.userId === Number(userCompletedByMost))
?.displayName
} `,
?.displayName || 'Unknown'
}`,
},
// contributes:
{
icon: <Group />,
text: 'Total Performers',
subtext: `${Object.keys(userHistories).length} users`,
text: 'Team Members',
subtext: `${Object.keys(userHistories).length} active`,
},
{
icon: <Avatar />,
text: 'Last Completed',
icon: <Analytics />,
text: 'Last Completed By',
subtext: `${
performers.find(p => p.userId === Number(histories[0].completedBy))
?.displayName
?.displayName || 'Unknown'
}`,
},
]
@@ -183,42 +189,67 @@ const ChoreHistory = () => {
return (
<Container maxWidth='md'>
<Typography level='title-md' mb={1.5}>
Summary:
</Typography>
<Sheet
// sx={{
// mb: 1,
// borderRadius: 'lg',
// p: 2,
// }}
sx={{ borderRadius: 'sm', p: 2 }}
variant='outlined'
>
<Grid container spacing={1}>
{/* Enhanced Header Section */}
<Box sx={{ mb: 4 }}>
{/* Statistics Cards Grid */}
<Grid container spacing={1} sx={{ mb: 1 }}>
{historyInfo.map((info, index) => (
<Grid item xs={4} key={index}>
{/* divider between the list items: */}
<ListItem key={index}>
<ListItemContent>
<Typography level='body-xs' sx={{ fontWeight: 'md' }}>
{info.text}
</Typography>
<Chip color='primary' size='md' startDecorator={info.icon}>
{info.subtext ? info.subtext : '--'}
</Chip>
</ListItemContent>
</ListItem>
<Grid item xs={6} sm={6} key={index}>
<Card
variant='soft'
sx={{
borderRadius: 'md',
boxShadow: 1,
px: 2,
py: 1,
minHeight: 90,
height: '100%',
justifyContent: 'start',
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
mb: 0.5,
}}
>
{info.icon}
<Typography
level='body-md'
sx={{
ml: 1,
fontWeight: '500',
color: 'text.primary',
}}
>
{info.text}
</Typography>
</Box>
<Box>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
>
{info.subtext || '--'}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Sheet>
</Box>
{/* User History Cards */}
<Typography level='title-md' my={1.5}>
History:
</Typography>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Completion History
</Typography>
</Box>
<Sheet variant='plain' sx={{ borderRadius: 'sm', boxShadow: 'md' }}>
{/* Chore History List (Updated Style) */}

View File

@@ -0,0 +1,365 @@
import {
Box,
Button,
Card,
CircularProgress,
FormControl,
FormLabel,
Input,
Option,
Select,
Typography,
} from '@mui/joy'
import { data } from 'autoprefixer'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import FadeModal from '../../../components/common/FadeModal'
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
function UserDeletionModal({ isOpen, onClose, userProfile }) {
const Navigate = useNavigate()
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
const [password, setPassword] = useState('')
const [confirmation, setConfirmation] = useState('')
const [transferOptions, setTransferOptions] = useState([])
const [circlesRequiringTransfer, setCirclesRequiringTransfer] = useState([])
const [availableMembers, setAvailableMembers] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const resetModal = useCallback(() => {
setStep(1)
setPassword('')
setConfirmation('')
setTransferOptions([])
setCirclesRequiringTransfer([])
setAvailableMembers([])
setError('')
}, [])
const handleClose = useCallback(
success => {
resetModal()
onClose(success)
},
[onClose, resetModal],
)
const checkDeletionRequirements = async () => {
if (password.trim() === '') {
setError('Please enter your password to continue')
return
}
setLoading(true)
setError('')
try {
const response = await CheckUserDeletion(password)
const data = await response.json()
if (response.ok) {
if (data.requiresTransfer && data.circles) {
setCirclesRequiringTransfer(data.circles)
setAvailableMembers(data.availableMembers || [])
setStep(2)
} else {
setStep(3)
}
} else {
setError(data.error || 'Failed to check deletion requirements')
}
} catch (err) {
setError(data.error || 'Failed to check deletion requirements')
} finally {
setLoading(false)
}
}
const handleTransferSelection = (circleId, newOwnerId, newOwnerName) => {
setTransferOptions(prev => {
const existing = prev.find(t => t.circleId === circleId)
if (existing) {
return prev.map(t =>
t.circleId === circleId ? { ...t, newOwnerId, newOwnerName } : t,
)
} else {
return [...prev, { circleId, newOwnerId, newOwnerName }]
}
})
}
const proceedToConfirmation = () => {
if (circlesRequiringTransfer.length === transferOptions.length) {
setStep(3)
}
}
const executeUserDeletion = async () => {
if (password.trim() === '' || confirmation !== 'DELETE') {
setError('Please enter your password and type DELETE to confirm')
return
}
setLoading(true)
setError('')
try {
const response = await DeleteUser(password, confirmation, transferOptions)
const data = await response.json()
console.log(response)
if (response.status === 200) {
// Clear authentication tokens
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
Navigate('/login', { replace: true })
handleClose(true)
// Redirect to login or home page after successful deletion
} else {
setError(data.message || 'Failed to delete account')
}
} catch (err) {
setError('Failed to delete account')
} finally {
setLoading(false)
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = event => {
if (!isOpen) return
if (event.key === 'Escape') {
event.preventDefault()
handleClose(false)
return
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleClose])
const renderWarningStep = () => (
<>
<Typography level='h4' mb={2} color='danger'>
Delete Account
</Typography>
<Typography level='body-md' mb={2}>
<strong>This action cannot be undone.</strong> Deleting your account
will permanently remove:
</Typography>
<Box mb={3}>
<Typography level='body-sm' mb={1}>
Your user profile and authentication data
</Typography>
<Typography level='body-sm' mb={1}>
All your chores, chore history, and time tracking sessions
</Typography>
<Typography level='body-sm' mb={1}>
API tokens, MFA sessions, and password reset tokens
</Typography>
<Typography level='body-sm' mb={1}>
Storage files and usage data
</Typography>
<Typography level='body-sm' mb={1}>
Points history and notifications
</Typography>
<Typography level='body-sm' mb={1}>
Circle memberships and relationships
</Typography>
</Box>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Enter your password to continue</FormLabel>
<Input
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='Enter your password'
/>
</FormControl>
{error && (
<Typography level='body-sm' color='danger' mb={2}>
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
Cancel
</Button>
<Button
color='danger'
onClick={checkDeletionRequirements}
loading={loading}
disabled={!password}
fullWidth
>
Continue
</Button>
</Box>
</>
)
const renderTransferStep = () => (
<>
<Typography level='h4' mb={2} color='warning'>
Circle Ownership Transfer Required
</Typography>
<Typography level='body-md' mb={3}>
You own circles that require ownership transfer before deletion. Please
select new owners:
</Typography>
{circlesRequiringTransfer.map(circle => (
<Card key={circle.id} sx={{ mb: 2, p: 2 }}>
<Typography level='title-sm' mb={1}>
Circle: {circle.name}
</Typography>
<FormControl>
<FormLabel>New Owner</FormLabel>
<Select
placeholder='Select new owner'
value={
transferOptions.find(t => t.circleId === circle.id)
?.newOwnerId || ''
}
onChange={(_, value) => {
const member = availableMembers.find(m => m.id === value)
if (member) {
handleTransferSelection(circle.id, value, member.displayName)
}
}}
>
{availableMembers
.filter(member => circle.members.includes(member.id))
.map(member => (
<Option key={member.id} value={member.id}>
{member.displayName}
</Option>
))}
</Select>
</FormControl>
</Card>
))}
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
Cancel
</Button>
<Button
color='primary'
onClick={proceedToConfirmation}
disabled={circlesRequiringTransfer.length !== transferOptions.length}
fullWidth
>
Continue
</Button>
</Box>
</>
)
const renderConfirmationStep = () => (
<>
<Typography level='h4' mb={2} color='danger'>
Final Confirmation
</Typography>
<Typography level='body-md' mb={3}>
Please enter your password and type <strong>DELETE</strong> to confirm
account deletion.
</Typography>
<Typography level='body-sm' mb={2}>
on successful deletion, you will be logged out and redirected to the
login page.
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Password</FormLabel>
<Input
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='Enter your password'
/>
</FormControl>
<FormControl sx={{ mb: 3 }}>
<FormLabel>Type "DELETE" to confirm</FormLabel>
<Input
value={confirmation}
onChange={e => setConfirmation(e.target.value)}
placeholder='DELETE'
/>
</FormControl>
{error && (
<Typography level='body-sm' color='danger' mb={2}>
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
Cancel
</Button>
<Button
color='danger'
onClick={executeUserDeletion}
loading={loading}
disabled={!password || confirmation !== 'DELETE'}
fullWidth
>
Delete Account
</Button>
</Box>
</>
)
const renderStep = () => {
switch (step) {
case 1:
return renderWarningStep()
case 2:
return renderTransferStep()
case 3:
return renderConfirmationStep()
default:
return renderWarningStep()
}
}
return (
<FadeModal
open={isOpen}
onClose={() => handleClose(false)}
size='md'
unmountDelay={250}
>
{loading && step === 1 ? (
<Box
display='flex'
justifyContent='center'
alignItems='center'
minHeight={200}
>
<CircularProgress />
</Box>
) : (
renderStep()
)}
</FadeModal>
)
}
export default UserDeletionModal

View File

@@ -1,3 +1,4 @@
import { Capacitor } from '@capacitor/core'
import {
Box,
Button,
@@ -14,9 +15,11 @@ import {
Select,
Typography,
} from '@mui/joy'
import { Purchases } from '@revenuecat/purchases-capacitor'
import moment from 'moment'
import { useEffect, useState } from 'react'
import RealTimeSettings from '../../components/RealTimeSettings'
import SubscriptionModal from '../../components/SubscriptionModal'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
@@ -26,7 +29,6 @@ import {
DeleteCircleMember,
GetAllCircleMembers,
GetCircleMemberRequests,
GetSubscriptionSession,
GetUserCircle,
JoinCircle,
LeaveCircle,
@@ -38,6 +40,7 @@ import { isPlusAccount } from '../../utils/Helpers'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
import APITokenSettings from './APITokenSettings'
import MFASettings from './MFASettings'
import NotificationSetting from './NotificationSetting'
@@ -58,6 +61,8 @@ const Settings = () => {
const [isAdmin, setIsAdmin] = useState(false)
const [changePasswordModal, setChangePasswordModal] = useState(false)
const [subscriptionModal, setSubscriptionModal] = useState(false)
const [userDeletionModal, setUserDeletionModal] = useState(false)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
@@ -127,7 +132,7 @@ const Settings = () => {
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
} else if (userProfile?.subscription === 'canceled') {
} else if (userProfile?.subscription === 'cancelled') {
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
@@ -138,7 +143,7 @@ const Settings = () => {
const getSubscriptionStatus = () => {
if (userProfile?.subscription === 'active') {
return `Plus`
} else if (userProfile?.subscription === 'canceled') {
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return `Plus(until ${moment(userProfile?.expiration).format(
'MMM DD, YYYY',
@@ -594,17 +599,45 @@ const Settings = () => {
}}
disabled={
userProfile?.subscription === 'active' ||
moment(userProfile?.expiration).isAfter(moment())
(moment(userProfile?.expiration).isAfter(moment()) &&
userProfile?.subscription !== 'cancelled')
}
onClick={() => {
GetSubscriptionSession().then(data => {
data.json().then(data => {
console.log(data)
window.location.href = data.sessionURL
// open in new window:
// window.open(data.sessionURL, '_blank')
})
})
onClick={async () => {
if (Capacitor.isNativePlatform()) {
try {
const { RevenueCatUI } = await import(
'@revenuecat/purchases-capacitor-ui'
)
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
appUserID: String(userProfile?.id),
})
const offering = await Purchases.getOfferings()
await RevenueCatUI.presentPaywall({
offering: offering.current,
})
// Check if user now has entitlement after paywall interaction
const customerInfo = await Purchases.getCustomerInfo()
if (customerInfo.entitlements.active['plus']) {
showNotification({
type: 'success',
message:
'Purchase successful! Please restart the app to access Plus features.',
})
}
} catch (error) {
if (error.code !== '1') {
// User cancelled
showNotification({
type: 'error',
message: 'Purchase failed. Please try again.',
})
}
}
} else {
setSubscriptionModal(true)
}
}}
>
Upgrade
@@ -674,6 +707,23 @@ const Settings = () => {
) : null}
</Box>
)}
<Box>
<Typography level='title-md' mb={1} color='danger'>
Danger Zone
</Typography>
<Typography level='body-sm' mb={2} color='neutral'>
Once you delete your account, there is no going back. Please be
certain.
</Typography>
<Button
variant='outlined'
color='danger'
onClick={() => setUserDeletionModal(true)}
>
Delete Account
</Button>
</Box>
</div>
<NotificationSetting />
<MFASettings />
@@ -693,6 +743,25 @@ const Settings = () => {
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
<SubscriptionModal
open={subscriptionModal}
onClose={() => setSubscriptionModal(false)}
/>
<UserDeletionModal
isOpen={userDeletionModal}
onClose={success => {
setUserDeletionModal(false)
if (success) {
showNotification({
type: 'success',
message: 'Account deleted successfully',
})
}
}}
userProfile={userProfile}
/>
</Container>
)
}

View File

@@ -1,8 +1,20 @@
import { EventBusy, Schedule, TrendingUp } from '@mui/icons-material'
import {
Analytics,
BarChart,
CallReceived,
EventBusy,
Schedule,
Speed,
Timeline,
TrendingUp,
Update,
} from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Card,
CardContent,
Chip,
Container,
Grid,
@@ -10,8 +22,10 @@ import {
ListDivider,
ListItem,
ListItemContent,
Stack,
Typography,
} from '@mui/joy'
import { useTheme } from '@mui/joy/styles'
import moment from 'moment'
import { Link, useParams } from 'react-router-dom'
import {
@@ -22,7 +36,6 @@ import {
XAxis,
YAxis,
} from 'recharts'
import { useTheme } from '@mui/joy/styles'
import { useThingHistory } from '../../queries/ThingQueries'
import LoadingComponent from '../components/Loading'
@@ -41,6 +54,74 @@ const ThingsHistory = () => {
// Flatten all pages of history data
const thingsHistory = data?.pages.flatMap(page => page.res) || []
// Calculate analytics data
const calculateAnalytics = () => {
if (!thingsHistory.length) return []
// Calculate average update frequency
let avgUpdateFrequency = '--'
if (thingsHistory.length > 1) {
const oldestUpdate = moment(
thingsHistory[thingsHistory.length - 1].createdAt,
)
const newestUpdate = moment(thingsHistory[0].createdAt)
const totalDuration = newestUpdate.diff(oldestUpdate, 'hours')
const frequency = totalDuration / (thingsHistory.length - 1)
avgUpdateFrequency =
frequency < 1
? `${Math.round(frequency * 60)} minutes`
: frequency < 24
? `${Math.round(frequency)} hours`
: `${Math.round(frequency / 24)} days`
}
const lastUpdated = thingsHistory[0]
? moment(thingsHistory[0].updatedAt).fromNow()
: '--'
// Calculate update trend value
let updateTrend = '--'
if (thingsHistory.length >= 3) {
const diffs = thingsHistory
.map((h, i, arr) =>
i < arr.length - 1
? moment(h.createdAt).diff(arr[i + 1].createdAt, 'minutes')
: null,
)
.filter(d => d !== null)
const last = diffs[0]
const prev = diffs[1]
if (last > prev) updateTrend = 'Interval increasing'
else if (last < prev) updateTrend = 'Interval decreasing'
else updateTrend = 'Interval stable'
}
return [
{
icon: <Speed />,
text: 'Update Frequency',
subtext: `Every ${avgUpdateFrequency}`,
},
{
icon: <Update />,
text: 'Last Updated',
subtext: lastUpdated,
},
{
icon: <CallReceived />,
text: 'Last Value',
subtext: thingsHistory[0]?.state ?? '--',
},
{
icon: <TrendingUp />,
text: 'Update Trend',
subtext: updateTrend,
},
]
}
const analyticsData = calculateAnalytics()
const handleLoadMore = () => {
fetchNextPage()
}
@@ -106,71 +187,149 @@ const ThingsHistory = () => {
return (
<Container maxWidth='md'>
<Typography level='h3' mb={1.5}>
History:
</Typography>
{/* Enhanced Analytics Header Section */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<BarChart sx={{ fontSize: '2rem', color: 'primary.500' }} />
<Stack>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Things Details
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Quick overview of the thing's history and analytics
</Typography>
</Stack>
</Box>
{/* Statistics Cards Grid */}
<Grid container spacing={1} sx={{ mb: 1 }}>
{analyticsData.map((info, index) => (
<Grid xs={6} sm={6} key={index}>
<Card
variant='soft'
sx={{
borderRadius: 'md',
boxShadow: 1,
px: 2,
py: 1,
minHeight: 90,
height: '100%',
justifyContent: 'start',
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
mb: 0.5,
}}
>
{info.icon}
<Typography
level='body-md'
sx={{
ml: 1,
fontWeight: '500',
color: 'text.primary',
}}
>
{info.text}
</Typography>
</Box>
<Box>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
>
{info.subtext || '--'}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
{/* Chart Section Header */}
{thingsHistory.every(history => !isNaN(history.state)) &&
thingsHistory.length > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography
level='h4'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Data Visualization
</Typography>
</Box>
)}
{/* check if all the states are number the show it: */}
{thingsHistory.every(history => !isNaN(history.state)) &&
thingsHistory.length > 1 && (
<>
<Typography level='h4' gutterBottom>
Chart:
</Typography>
<Box sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md', mb: 4 }}>
<ResponsiveContainer width='100%' height={200}>
<LineChart
width={500}
height={300}
data={thingsHistory.toReversed()}
>
{/* <CartesianGrid strokeDasharray='3 3' /> */}
<XAxis
dataKey='updatedAt'
hide='true'
tick='false'
tickLine='false'
axisLine='false'
tickFormatter={tick =>
moment(tick).format('ddd MM/DD/yyyy HH:mm:ss')
}
/>
<YAxis
hide='true'
dataKey='state'
tick='false'
tickLine='true'
axisLine='false'
/>
<Tooltip
labelFormatter={label =>
moment(label).format('ddd MM/DD/yyyy HH:mm:ss')
}
/>
<Box sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md', mb: 2 }}>
<ResponsiveContainer width='100%' height={200}>
<LineChart
width={500}
height={300}
data={thingsHistory.toReversed()}
>
{/* <CartesianGrid strokeDasharray='3 3' /> */}
<XAxis
dataKey='updatedAt'
hide='true'
tick='false'
tickLine='false'
axisLine='false'
tickFormatter={tick =>
moment(tick).format('ddd MM/DD/yyyy HH:mm:ss')
}
/>
<YAxis
hide='true'
dataKey='state'
tick='false'
tickLine='true'
axisLine='false'
/>
<Tooltip
labelFormatter={label =>
moment(label).format('ddd MM/DD/yyyy HH:mm:ss')
}
/>
<Line
type='monotone'
dataKey='state'
stroke={theme.palette.primary[500]}
activeDot={{
r: 8,
fill: theme.palette.primary[600],
stroke: theme.palette.primary[300],
}}
dot={{
r: 4,
fill: theme.palette.primary[500],
stroke: theme.palette.primary[300],
}}
/>
</LineChart>
</ResponsiveContainer>
</Box>
</>
<Line
type='monotone'
dataKey='state'
stroke={theme.palette.primary[500]}
activeDot={{
r: 8,
fill: theme.palette.primary[600],
stroke: theme.palette.primary[300],
}}
dot={{
r: 4,
fill: theme.palette.primary[500],
stroke: theme.palette.primary[300],
}}
/>
</LineChart>
</ResponsiveContainer>
</Box>
)}
<Typography level='h4' gutterBottom>
Change log:
</Typography>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Change History
</Typography>
</Box>
<Box sx={{ borderRadius: 'sm', p: 1, boxShadow: 'md' }}>
<List sx={{ p: 0 }}>
{thingsHistory.map((history, index) => (

View File

@@ -3,7 +3,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CircleIcon from '@mui/icons-material/Circle'
import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import { EventBusy, Group, Toll } from '@mui/icons-material'
import { EventBusy, Group, Timeline, Toll } from '@mui/icons-material'
import {
Avatar,
Box,
@@ -94,9 +94,12 @@ const ChoreHistoryTimeline = ({ history }) => {
return (
<Container sx={{ p: 2 }}>
<Typography level='h4' sx={{ mb: 2 }}>
Activities Timeline
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Activities Timeline
</Typography>
</Box>
{Object.entries(groupedHistory).map(([date, items]) => (
<Box key={date} sx={{ mb: 4 }}>
@@ -785,6 +788,7 @@ const UserActivites = () => {
)
}
// Calculate activities analytics
return (
<Container
maxWidth='lg'
@@ -794,16 +798,6 @@ const UserActivites = () => {
px: { xs: 2, sm: 3 },
}}
>
<Typography
mb={3}
level='h4'
sx={{
alignSelf: 'flex-start',
}}
>
Activities Overview
</Typography>
{/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */}
<Box
sx={{
@@ -1040,24 +1034,17 @@ const UserActivites = () => {
<Card
variant='plain'
sx={{
// maxHeight: { lg: '90vh' },
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
width: { xs: '100%', lg: '315px' },
mr: { xs: 0, lg: 10 },
mb: 1,
}}
// variant='outlined'
// sx={{
// p: 2,
// borderRadius: 12,
// backdropFilter: 'blur(10px)',
// }}
>
<Stack spacing={3}>
{/* Main Chart */}

View File

@@ -7,14 +7,29 @@ import {
YAxis,
} from 'recharts'
import { CreditCard, Toll } from '@mui/icons-material'
import {
AccountBalanceWallet,
Analytics,
CreditCard,
EmojiEvents,
MilitaryTech,
Redeem,
Star,
SwapHoriz,
Timeline,
Toll,
TrendingUp,
WorkspacePremium,
} from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Card,
CardContent,
Chip,
Container,
Grid,
Option,
Select,
Stack,
@@ -34,6 +49,7 @@ import RedeemPointsModal from '../Modals/RedeemPointsModal'
const UserPoints = () => {
const [tabValue, setTabValue] = useState(7)
const [isRedeemModalOpen, setIsRedeemModalOpen] = useState(false)
const [leaderboardMode, setLeaderboardMode] = useState('points') // 'points' or 'tasks'
const {
data: circleMembersData,
@@ -206,24 +222,371 @@ const UserPoints = () => {
return <LoadingComponent />
}
// Calculate leaderboard data for the current time period
const calculateLeaderboard = () => {
if (!choresHistoryData || !circleUsers.length) return []
// Calculate points for each user in the current time period
const userPeriodStats = {}
// Initialize stats for all users
circleUsers.forEach(user => {
userPeriodStats[user.userId] = {
userId: user.userId,
displayName: user.displayName,
image: user.image,
totalPoints: user.points || 0,
availablePoints: (user.points || 0) - (user.pointsRedeemed || 0),
periodPoints: 0,
periodTasks: 0,
}
})
// Calculate period-specific stats from history
choresHistoryData.forEach(historyEntry => {
const userId = historyEntry.completedBy
if (userPeriodStats[userId]) {
userPeriodStats[userId].periodPoints += historyEntry.points || 0
userPeriodStats[userId].periodTasks += 1
}
})
// Convert to array and sort by selected mode
const sortField =
leaderboardMode === 'points' ? 'periodPoints' : 'periodTasks'
return Object.values(userPeriodStats)
.sort((a, b) => b[sortField] - a[sortField])
.map((user, index) => ({
...user,
rank: index + 1,
avgPointsPerTask:
user.periodTasks > 0
? (user.periodPoints / user.periodTasks).toFixed(1)
: 0,
}))
}
const leaderboardData = calculateLeaderboard()
// Get trophy icons for top 3
const getTrophyIcon = rank => {
switch (rank) {
case 1:
return <EmojiEvents sx={{ color: '#FFD700', fontSize: '1.2rem' }} /> // Gold
case 2:
return (
<WorkspacePremium sx={{ color: '#C0C0C0', fontSize: '1.2rem' }} />
) // Silver
case 3:
return <MilitaryTech sx={{ color: '#CD7F32', fontSize: '1.2rem' }} /> // Bronze
default:
return <Star sx={{ color: 'text.secondary', fontSize: '1rem' }} />
}
}
return (
<Container
maxWidth='xl'
maxWidth='md'
sx={{
display: 'flex',
flexDirection: 'column',
px: { xs: 2, sm: 3 },
}}
>
<Typography
mb={3}
level='h4'
sx={{
alignSelf: 'flex-start',
}}
>
Points Overview
</Typography>
{/* Enhanced Leaderboard Header Section */}
<Box sx={{ mb: 4 }}>
<Stack spacing={2}>
{/* Title Row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} />
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
{leaderboardMode === 'points' ? 'Points' : 'Tasks'} Leaderboard
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Rankings based on{' '}
{leaderboardMode === 'points'
? 'points earned'
: 'tasks completed'}{' '}
during the selected time period
</Typography>
</Stack>
</Box>
{/* Filters Row - Responsive */}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
alignItems: { xs: 'stretch', sm: 'center' },
justifyContent: { xs: 'flex-start', sm: 'space-between' },
}}
>
{/* Time Period Filter */}
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Tabs
onChange={(e, tabValue) => {
setTabValue(tabValue)
handleChoresHistoryLimitChange(tabValue)
}}
value={tabValue}
size='sm'
sx={{
borderRadius: 6,
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<TabList
disableUnderline
sx={{
borderRadius: 6,
backgroundColor: 'transparent',
p: 0.3,
gap: 0.3,
}}
>
{[
{ label: '7D', value: 7 },
{ label: '6M', value: 6 * 30 },
{ label: 'All', value: 24 * 30 },
].map((tab, index) => (
<Tab
key={index}
sx={{
borderRadius: 4,
minWidth: 'auto',
px: 1.5,
py: 0.5,
fontSize: 'xs',
fontWeight: 500,
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.plainColor',
backgroundColor: 'primary.softBg',
fontWeight: 600,
},
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
},
}}
disableIndicator
value={tab.value}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</Box>
{/* Toggle between points and tasks */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: { xs: 'center', sm: 'flex-end' },
gap: 1,
mb: 1,
}}
>
<Chip
variant={leaderboardMode === 'points' ? 'solid' : 'outlined'}
color='primary'
size='sm'
sx={{ cursor: 'pointer' }}
onClick={() => setLeaderboardMode('points')}
>
Points
</Chip>
<SwapHoriz
sx={{ fontSize: '0.875rem', color: 'text.tertiary' }}
/>
<Chip
variant={leaderboardMode === 'tasks' ? 'solid' : 'outlined'}
color='primary'
size='sm'
sx={{ cursor: 'pointer' }}
onClick={() => setLeaderboardMode('tasks')}
>
Tasks
</Chip>
</Box>
</Box>
</Stack>
{/* Leaderboard Cards */}
<Card
variant='outlined'
sx={{ borderRadius: 'lg', overflow: 'hidden' }}
>
<Stack spacing={0}>
{leaderboardData.map((user, index) => (
<Box key={user.userId}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 2.5,
backgroundColor:
user.userId === userProfile.id
? 'primary.softBg'
: 'transparent',
position: 'relative',
'&:hover': {
backgroundColor:
user.userId === userProfile.id
? 'primary.softHoverBg'
: 'neutral.softHoverBg',
},
cursor:
user.userId === selectedUser ? 'default' : 'pointer',
transition: 'background-color 0.2s ease',
}}
onClick={() => {
if (user.userId !== selectedUser) {
setSelectedUser(user.userId)
setSelectedHistory(
generateWeeklySummary(choresHistoryData, user.userId),
)
}
}}
>
{/* Rank Badge */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 40,
mr: 2,
}}
>
{getTrophyIcon(user.rank)}
<Typography
level='body-sm'
sx={{
ml: 0.5,
fontWeight: user.rank <= 3 ? 'bold' : 'normal',
color:
user.rank <= 3 ? 'text.primary' : 'text.secondary',
}}
>
#{user.rank}
</Typography>
</Box>
{/* User Avatar and Info */}
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<Avatar
src={user.image ? resolvePhotoURL(user.image) : undefined}
sx={{ width: 40, height: 40, mr: 2 }}
>
{user.displayName?.charAt(0)}
</Avatar>
<Stack sx={{ flex: 1 }}>
<Typography
level='body-md'
sx={{
fontWeight:
user.userId === userProfile.id ? 'bold' : 'normal',
color: 'text.primary',
}}
>
{user.displayName}
{user.userId === userProfile.id && (
<Chip
size='sm'
variant='soft'
color='primary'
sx={{ ml: 1 }}
>
You
</Chip>
)}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'text.secondary' }}
>
{user.periodTasks} tasks {user.avgPointsPerTask} avg
per task
</Typography>
</Stack>
</Box>
{/* Metric Display */}
<Stack alignItems='flex-end' spacing={0.5}>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
<Toll sx={{ fontSize: '1rem', color: 'success.500' }} />
<Typography
level='title-md'
sx={{
fontWeight: 'bold',
color: 'success.600',
}}
>
{leaderboardMode === 'points'
? user.periodPoints
: user.periodTasks}
</Typography>
</Box>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{leaderboardMode === 'points'
? `${user.availablePoints} available`
: `${user.periodPoints} points`}
</Typography>
</Stack>
{/* Selection Indicator */}
{user.userId === selectedUser && (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
backgroundColor: 'primary.500',
borderRadius: '0 4px 4px 0',
}}
/>
)}
</Box>
{index < leaderboardData.length - 1 && (
<Box
sx={{
height: 1,
backgroundColor: 'divider',
mx: 2.5,
}}
/>
)}
</Box>
))}
</Stack>
</Card>
</Box>
{/* Filters Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Filter & Analysis
</Typography>
</Box>
{/* Improved Filter Bar */}
<Card
@@ -397,6 +760,107 @@ const UserPoints = () => {
</Stack>
</Card>
{/* Points Status Cards */}
<Grid container spacing={1} sx={{ mb: 3 }}>
{(() => {
const selectedUserData = circleUsers.find(
user => user.userId === selectedUser,
)
const totalPoints = selectedUserData?.points || 0
const redeemedPoints = selectedUserData?.pointsRedeemed || 0
const availablePoints = totalPoints - redeemedPoints
const periodStats = leaderboardData.find(
user => user.userId === selectedUser,
)
const periodPoints = selectedHistory.reduce(
(sum, item) => sum + (item.points || 0),
0,
)
const pointsCards = [
{
icon: <AccountBalanceWallet />,
title: 'Total',
text: `${totalPoints} points`,
subtext: 'All time earned',
},
{
icon: <Toll />,
title: 'Available',
text: `${availablePoints} points`,
subtext: 'Ready to redeem',
},
{
icon: <TrendingUp />,
title: 'Period Points',
text: `${periodPoints} points`,
subtext: `${tabValue === 24 * 30 ? 'All time' : tabValue === 6 * 30 ? 'Last 6 months' : `Last ${tabValue} days`}`,
},
{
icon: <Redeem />,
title: 'Redeemed',
text: `${redeemedPoints} points`,
subtext: 'Previously used',
},
]
return pointsCards.map((card, index) => (
<Grid item xs={6} sm={6} key={index}>
<Card
variant='soft'
sx={{
borderRadius: 'md',
boxShadow: 1,
px: 2,
py: 1,
minHeight: 90,
height: '100%',
justifyContent: 'start',
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
mb: 0.5,
}}
>
{card.icon}
<Typography
level='body-md'
sx={{
ml: 1,
fontWeight: '500',
color: 'text.primary',
}}
>
{card.title}
</Typography>
</Box>
<Box>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
>
{card.text}
</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', lineHeight: 1.5 }}
>
{card.subtext}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
))
})()}
</Grid>
{/* Current Filter Summary */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
@@ -430,107 +894,15 @@ const UserPoints = () => {
gap: 3,
}}
>
{/* Points Cards */}
<Box
sx={{
// resposive width based on parent available space:
width: '100%',
display: 'flex',
justifyContent: 'space-evenly',
gap: 1,
}}
>
{[
{
title: 'Total',
value: circleMembersData.res.find(
user => user.userId === selectedUser,
)?.points,
color: 'primary',
},
{
title: 'Available',
value: (function () {
const user = circleMembersData.res.find(
user => user.userId === selectedUser,
)
if (!user) return 0
return user.points - user.pointsRedeemed
})(),
color: 'success',
},
{
title: 'Redeemed',
value: circleMembersData.res.find(
user => user.userId === selectedUser,
)?.pointsRedeemed,
color: 'warning',
},
].map(card => (
<Card
key={card.title}
sx={{
p: 2,
mb: 1,
minWidth: 80,
width: '100%',
}}
variant='soft'
>
<Typography level='body-xs' textAlign='center' mb={-1}>
{card.title}
</Typography>
<Typography level='title-md' textAlign='center'>
{card.value}
</Typography>
</Card>
))}
</Box>
{/* Points History Section */}
<Typography level='h4' sx={{ mt: 2, mb: 2 }}>
Points History
</Typography>
<Box
sx={{
// resposive width based on parent available space:
width: '100%',
display: 'flex',
justifyContent: 'left',
gap: 1,
mb: 3,
}}
>
{[
{
title: 'Points',
value: selectedHistory.reduce((acc, cur) => acc + cur.points, 0),
color: 'success',
},
{
title: 'Tasks',
value: selectedHistory.reduce((acc, cur) => acc + cur.tasks, 0),
color: 'primary',
},
].map(card => (
<Card
key={card.title}
sx={{
p: 2,
mb: 1,
width: 250,
}}
variant='soft'
>
<Typography level='body-xs' textAlign='center' mb={-1}>
{card.title}
</Typography>
<Typography level='title-md' textAlign='center'>
{card.value}
</Typography>
</Card>
))}
{/* Chart Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography
level='h4'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Points Trend
</Typography>
</Box>
{/* Bar Chart for points overtime */}