import { LocalNotifications } from '@capacitor/local-notifications' import { Refresh, Star, Token } from '@mui/icons-material' import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useState } from 'react' import { networkManager } from '../../hooks/NetworkManager' import useConfirmationModal from '../../hooks/useConfirmationModal' import { useSSEContext } from '../../hooks/useSSEContext' import { useUserProfile } from '../../queries/UserQueries' import { evaluatePromptEligibility, isFeedbackSubmissionConfigured, isRawChatWebhookConfigured, requestStoreReview, resetFeedbackState, setDevForcedPrompt, } from '../../service/FeedbackService' import { useNotification } from '../../service/NotificationProvider' import { resetPolicyUpdate } from '../../service/PolicyUpdateService' import { apiClient } from '../../utils/ApiClient' import { commandQueue } from '../../utils/CommandQueue' import { RefreshToken } from '../../utils/Fetcher' import { offlineDB } from '../../utils/OfflineDB' import { syncEngine } from '../../utils/SyncEngine' import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage' import FeedbackModal from '../Modals/FeedbackModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import PolicyUpdateModal from '../Modals/PolicyUpdateModal' const DeveloperSettings = () => { const queryClient = useQueryClient() const { confirmModalConfig, showConfirmation } = useConfirmationModal() const { data: userProfile } = useUserProfile() const { error: sseError, getConnectionStatus, getDebugInfo, isConnected, isConnecting, lastEvent, } = useSSEContext() const [accessTokenExpiry, setAccessTokenExpiry] = useState(null) const [refreshTokenExpiry, setRefreshTokenExpiry] = useState(null) const [timeLeft, setTimeLeft] = useState({ access: null, refresh: null, }) const [isNativePlatform, setIsNativePlatform] = useState(false) const [sseDebugInfo, setSSEDebugInfo] = useState(null) const [timeSinceLastHeartbeat, setTimeSinceLastHeartbeat] = useState(null) const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshingDirect, setIsRefreshingDirect] = useState(false) const [scheduledNotifications, setScheduledNotifications] = useState([]) const [isLoadingNotifications, setIsLoadingNotifications] = useState(false) const [isResettingSync, setIsResettingSync] = useState(false) const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) const [policyModalOpen, setPolicyModalOpen] = useState(false) const [feedbackEligibility, setFeedbackEligibility] = useState(null) const [syncDiagnostics, setSyncDiagnostics] = useState({ cursor: null, lastSync: null, pendingCount: 0, failedCount: 0, syncing: false, syncError: null, isOnline: networkManager.isOnline, isNetworkOn: networkManager.isNetworkOn, offlineSince: networkManager.offlineSince, lastChecked: networkManager.lastChecked, }) const { showNotification } = useNotification() const refreshSyncDiagnostics = useCallback(async () => { try { const [cursor, lastSync, pendingCommands, failedCommands] = await Promise.all([ offlineDB.getSyncCursor(), offlineDB.getLastSyncTime(), commandQueue.getPending(), commandQueue.getFailed(), ]) setSyncDiagnostics(prev => ({ ...prev, cursor, lastSync, pendingCount: pendingCommands.length, failedCount: failedCommands.length, isOnline: networkManager.isOnline, isNetworkOn: networkManager.isNetworkOn, offlineSince: networkManager.offlineSince, lastChecked: networkManager.lastChecked, })) } catch (error) { console.error('Failed to load sync diagnostics:', error) } }, []) useEffect(() => { setIsNativePlatform(isNative()) const loadTokenData = async () => { const accessExpiry = localStorage.getItem('token_expiry') setAccessTokenExpiry(accessExpiry) if (isNative()) { const refreshExpiry = await getRefreshTokenExpiry() setRefreshTokenExpiry(refreshExpiry) } } const loadScheduledNotifications = async () => { if (isNative()) { setIsLoadingNotifications(true) try { const pending = await LocalNotifications.getPending() // Sort by schedule time (earliest first) const sorted = pending.notifications.sort((a, b) => { const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0 const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0 return timeA - timeB }) setScheduledNotifications(sorted) } catch (error) { console.error('Error loading scheduled notifications:', error) } finally { setIsLoadingNotifications(false) } } } loadTokenData() loadScheduledNotifications() refreshSyncDiagnostics() }, [refreshSyncDiagnostics]) useEffect(() => { const unsubscribeSync = syncEngine.onSyncStateChange(state => { setSyncDiagnostics(prev => ({ ...prev, syncing: typeof state.syncing === 'boolean' ? state.syncing : prev.syncing, syncError: Object.prototype.hasOwnProperty.call(state, 'error') ? state.error : prev.syncError, lastSync: state.lastSync ?? prev.lastSync, })) }) networkManager.registerNetworkListener(() => { setSyncDiagnostics(prev => ({ ...prev, isOnline: networkManager.isOnline, isNetworkOn: networkManager.isNetworkOn, offlineSince: networkManager.offlineSince, lastChecked: networkManager.lastChecked, })) }) const interval = setInterval(() => { refreshSyncDiagnostics() }, 5000) return () => { unsubscribeSync() clearInterval(interval) } }, [refreshSyncDiagnostics]) useEffect(() => { const calculateTimeLeft = () => { const now = new Date() let accessTime = null if (accessTokenExpiry) { const accessExpiryDate = new Date(accessTokenExpiry) const diff = accessExpiryDate - now accessTime = diff > 0 ? diff : 0 } let refreshTime = null if (refreshTokenExpiry) { const refreshExpiryDate = new Date(refreshTokenExpiry) const diff = refreshExpiryDate - now refreshTime = diff > 0 ? diff : 0 } setTimeLeft({ access: accessTime, refresh: refreshTime, }) if (getDebugInfo) { const debugInfo = getDebugInfo() setSSEDebugInfo(debugInfo) setTimeSinceLastHeartbeat(debugInfo.timeSinceLastHeartbeat) } } calculateTimeLeft() const interval = setInterval(calculateTimeLeft, 1000) return () => clearInterval(interval) }, [accessTokenExpiry, refreshTokenExpiry, getDebugInfo]) const formatTimeLeft = milliseconds => { if (milliseconds === null) return 'N/A' if (milliseconds === 0) return 'Expired' const totalSeconds = Math.floor(milliseconds / 1000) const days = Math.floor(totalSeconds / 86400) const hours = Math.floor((totalSeconds % 86400) / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 const parts = [] if (days > 0) parts.push(`${days}d`) if (hours > 0) parts.push(`${hours}h`) if (minutes > 0) parts.push(`${minutes}m`) if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`) return parts.join(' ') } const getExpiryStatus = milliseconds => { if (milliseconds === null) return 'neutral' if (milliseconds === 0) return 'danger' if (milliseconds < 5 * 60 * 1000) return 'warning' // Less than 5 minutes return 'success' } const handleRefreshToken = async () => { setIsRefreshing(true) try { const result = await apiClient.refreshToken() if (result.success) { showNotification({ type: 'success', message: 'Token refreshed successfully', }) // Reload token expiry data const accessExpiry = localStorage.getItem('token_expiry') setAccessTokenExpiry(accessExpiry) if (isNativePlatform) { const refreshExpiry = await getRefreshTokenExpiry() setRefreshTokenExpiry(refreshExpiry) } } else { showNotification({ type: 'error', message: `Token refresh failed: ${result.error}`, }) } } catch (error) { showNotification({ type: 'error', message: `Token refresh error: ${error.message}`, }) } finally { setIsRefreshing(false) } } const handleDirectRefreshToken = async () => { setIsRefreshingDirect(true) try { const response = await RefreshToken() if (response.ok) { const data = await response.json() showNotification({ type: 'success', message: 'Refresh token endpoint called successfully', }) // Reload token expiry data const accessExpiry = localStorage.getItem('token_expiry') setAccessTokenExpiry(accessExpiry) if (isNativePlatform) { const refreshExpiry = await getRefreshTokenExpiry() setRefreshTokenExpiry(refreshExpiry) } console.log('Refresh token response:', data) } else { const error = await response.text() showNotification({ type: 'error', message: `Refresh token endpoint failed: ${response.status} ${error}`, }) } } catch (error) { showNotification({ type: 'error', message: `Refresh token endpoint error: ${error.message}`, }) } finally { setIsRefreshingDirect(false) } } const handleRefreshNotifications = async () => { if (!isNativePlatform) return setIsLoadingNotifications(true) try { const pending = await LocalNotifications.getPending() // Sort by schedule time (earliest first) const sorted = pending.notifications.sort((a, b) => { const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0 const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0 return timeA - timeB }) setScheduledNotifications(sorted) showNotification({ type: 'success', message: `Loaded ${sorted.length} scheduled notifications`, }) } catch (error) { console.error('Error loading scheduled notifications:', error) showNotification({ type: 'error', message: `Error loading notifications: ${error.message}`, }) } finally { setIsLoadingNotifications(false) } } const handleResetDatabaseAndResync = async () => { showConfirmation( 'This will clear local offline data and pending commands, then start a full sync from the beginning. Continue?', 'Clear Local DB & Re-Sync', async () => { setIsResettingSync(true) try { await offlineDB.clearAll() showNotification({ type: 'success', message: 'Local offline database cleared. Starting full sync...', }) const didSync = await syncEngine.sync() if (didSync) { await queryClient.invalidateQueries() showNotification({ type: 'success', message: 'Full sync completed from the beginning', }) } else { showNotification({ type: 'warning', message: 'Database cleared. Full sync did not run (likely offline or already syncing).', }) } } catch (error) { console.error('Failed to reset database and resync:', error) showNotification({ type: 'error', message: `Reset/resync failed: ${error.message}`, }) } finally { await refreshSyncDiagnostics() setIsResettingSync(false) } }, 'Clear & Re-Sync', 'Cancel', 'danger', ) } const refreshFeedbackEligibility = useCallback(async () => { const result = await evaluatePromptEligibility({ userProfile }) setFeedbackEligibility(result) return result }, [userProfile]) useEffect(() => { refreshFeedbackEligibility() }, [refreshFeedbackEligibility]) const handleForceFeedbackPrompt = async () => { await setDevForcedPrompt(true) await refreshFeedbackEligibility() showNotification({ type: 'success', message: 'Next visit to My Chores will show the prompt after ~4s', }) } const handleForcePolicyUpdate = async () => { await resetPolicyUpdate() showNotification({ type: 'success', message: 'Next visit to My Chores will show the policy notice after ~1.5s', }) } const handleResetFeedbackState = async () => { await resetFeedbackState() await refreshFeedbackEligibility() showNotification({ type: 'success', message: 'Feedback state cleared (completions, cooldown, opt-out)', }) } const handleRequestStoreReview = async () => { const requested = await requestStoreReview() await refreshFeedbackEligibility() showNotification({ type: requested ? 'success' : 'warning', message: requested ? 'Review requested. The OS decides whether to actually show it.' : 'Not available — native platform only.', }) } const getNotificationStatusColor = scheduleTime => { if (!scheduleTime) return 'neutral' const now = new Date() const scheduledDate = new Date(scheduleTime) const diffMs = scheduledDate - now if (diffMs < 0) return 'danger' // Past due if (diffMs < 5 * 60 * 1000) return 'warning' // Less than 5 minutes if (diffMs < 60 * 60 * 1000) return 'primary' // Less than 1 hour return 'success' // More than 1 hour } const formatDateTime = timestamp => { if (!timestamp) return 'N/A' return new Date(timestamp).toLocaleString() } return (
Developer Settings View technical information about your authentication tokens and session state. This information is useful for debugging and development purposes. Authentication Tokens Access Token Time Left: {formatTimeLeft(timeLeft.access)} {accessTokenExpiry && ( Expires: {new Date(accessTokenExpiry).toLocaleString()} )} Refresh Token {isNativePlatform ? ( <> Time Left: {formatTimeLeft(timeLeft.refresh)} {refreshTokenExpiry && ( Expires: {new Date(refreshTokenExpiry).toLocaleString()} )} ) : ( Refresh tokens are managed via HTTP-only cookies on web platform )} Platform Information Platform:{' '} {isNativePlatform ? 'Native' : 'Web'} Sync & Network Diagnostics Network Status Connection:{' '} {syncDiagnostics.isOnline ? 'Online' : 'Offline'} Device Network:{' '} {syncDiagnostics.isNetworkOn === false ? 'Disconnected' : syncDiagnostics.isNetworkOn === true ? 'Connected' : 'Unknown'} Offline Since: {formatDateTime(syncDiagnostics.offlineSince)} Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)} Sync Offset Information Sync Cursor:{' '} {syncDiagnostics.cursor ?? 'N/A'} Last Sync:{' '} {formatDateTime(syncDiagnostics.lastSync)} Sync State:{' '} {syncDiagnostics.syncing ? 'Syncing' : 'Idle'} Pending Commands:{' '} {syncDiagnostics.pendingCount} Failed Commands:{' '} 0 ? 'danger' : 'success'} > {syncDiagnostics.failedCount} {syncDiagnostics.syncError && ( Sync Error: {syncDiagnostics.syncError} )} Recovery Actions Clears local offline cache, sync cursor, and queued commands, then re-syncs from the beginning. Feedback & Review Prompt Prompt Eligibility Would auto-show:{' '} {feedbackEligibility?.eligible ? 'Yes' : 'No'} {feedbackEligibility?.forced && ( Forced )} {feedbackEligibility?.blockers?.length > 0 && ( {feedbackEligibility.blockers.map(blocker => ( • {blocker} ))} )} State Completions counted:{' '} {feedbackEligibility?.state?.completions ?? 'N/A'} Last sentiment:{' '} {feedbackEligibility?.state?.lastSentiment ?? 'None'} Dismissals:{' '} {feedbackEligibility?.state?.dismissCount ?? 0} Opted out:{' '} {feedbackEligibility?.state?.optedOut ? 'Yes' : 'No'} Last prompted:{' '} {formatDateTime(feedbackEligibility?.state?.lastPromptedAt)} {feedbackEligibility?.state?.lastPromptedVersion ? ` on ${feedbackEligibility.state.lastPromptedVersion}` : ''} Review requested:{' '} {formatDateTime(feedbackEligibility?.state?.reviewRequestedAt)} Current version: {feedbackEligibility?.version ?? 'N/A'} · Webhook{' '} {isFeedbackSubmissionConfigured() ? 'configured' : 'NOT configured (submissions log to console)'} {isRawChatWebhookConfigured() && ( VITE_FEEDBACK_WEBHOOK_URL points straight at a Discord/Slack webhook. Discord rejects that with 50006, and the URL ships inside the public bundle — deploy workers/feedback and point the variable at the Worker. )} Actions "Force next prompt" bypasses every gate, then open My Chores to see the automatic trigger. Policy Update Notice Shown once per POLICY_VERSION to accounts created before the policy effective date. "Force Next Prompt" clears the acknowledgement, then open My Chores to see the automatic trigger. {isNativePlatform && ( Scheduled Local Notifications {scheduledNotifications.length === 0 ? ( No scheduled notifications ) : ( Total scheduled:{' '} {scheduledNotifications.length} {scheduledNotifications.map((notification, index) => { const scheduleTime = notification.schedule?.at const scheduledDate = scheduleTime ? new Date(scheduleTime) : null const now = new Date() const timeUntil = scheduledDate ? scheduledDate - now : null return ( {notification.title || 'No title'} {notification.body || 'No body'} {scheduledDate && ( <> {timeUntil && timeUntil > 0 ? formatTimeLeft(timeUntil) : 'Past due'} {scheduledDate.toLocaleString()} )} {notification.extra?.choreId && ( Chore ID: {notification.extra.choreId} )} ) })} )} )} Server-Sent Events (SSE) Connection Status {getConnectionStatus ? getConnectionStatus().toUpperCase() : 'Unknown'} {sseError && ( Error: {sseError} )} Last Event Received {lastEvent ? ( <> Type:{' '} {lastEvent.type} Received:{' '} {lastEvent.timestamp ? new Date(lastEvent.timestamp).toLocaleString() : 'N/A'} ) : ( No events received yet )} Heartbeat Status {sseDebugInfo?.lastHeartbeat ? ( <> Last Heartbeat:{' '} {new Date(sseDebugInfo.lastHeartbeat).toLocaleString()} Time Since Last Heartbeat:{' '} 120000 ? 'warning' : 'success' } > {formatTimeLeft(timeSinceLastHeartbeat)} ) : ( No heartbeat received yet )} Reconnection Schedule {sseDebugInfo ? ( {sseDebugInfo.nextReconnectTime ? ( <> Next Reconnect:{' '} {new Date( sseDebugInfo.nextReconnectTime, ).toLocaleString()} Time Until Reconnect:{' '} {formatTimeLeft(sseDebugInfo.timeUntilReconnect)} Current Delay:{' '} {formatTimeLeft(sseDebugInfo.currentReconnectDelay)} ) : ( No reconnection scheduled )} ) : ( No reconnection information available )} Timeout Configuration {sseDebugInfo ? ( Heartbeat Timeout:{' '} {formatTimeLeft(sseDebugInfo.heartbeatTimeout)} Monitor Interval:{' '} {formatTimeLeft(sseDebugInfo.heartbeatMonitorInterval)} Monitor Timeout:{' '} {formatTimeLeft(sseDebugInfo.heartbeatMonitorTimeout)} Circuit Breaker Reset:{' '} {formatTimeLeft(sseDebugInfo.circuitBreakerResetTime)} ) : ( No timeout information available )} Debug Information {sseDebugInfo ? ( Reconnect Attempts:{' '} {sseDebugInfo.reconnectAttempts} /{' '} {sseDebugInfo.maxReconnectAttempts} Circuit Breaker:{' '} {sseDebugInfo.isCircuitBreakerOpen ? 'OPEN' : 'CLOSED'} Connection State:{' '} {sseDebugInfo.connectionState === 0 ? 'CONNECTING' : sseDebugInfo.connectionState === 1 ? 'OPEN' : 'CLOSED'} ) : ( No debug information available )} { setFeedbackModalOpen(false) refreshFeedbackEligibility() }} /> {/* Preview only — deliberately does not acknowledge, so opening it here never suppresses the real notice. */} setPolicyModalOpen(false)} />
) } export default DeveloperSettings