diff --git a/scripts/pull-secrets.sh b/scripts/pull-secrets.sh new file mode 100755 index 0000000..d0fee1f --- /dev/null +++ b/scripts/pull-secrets.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BW_SERVER="${BW_SERVER:-https://bitwarden.com}" + +# ── Auth ────────────────────────────────────────────────────────────────────── +echo "→ Connecting to Vaultwarden at $BW_SERVER" +CURRENT_SERVER=$(bw status | jq -r '.serverUrl // empty') + +if [ "$CURRENT_SERVER" != "$BW_SERVER" ]; then + bw logout || true + bw config server "$BW_SERVER" +fi + +if [ -z "${BW_SESSION:-}" ]; then + BW_LOGIN_STATUS=$(bw status | jq -r '.status') + + if [ "$BW_LOGIN_STATUS" = "unauthenticated" ]; then + if [ -n "${BW_CLIENTID:-}" ] && [ -n "${BW_CLIENTSECRET:-}" ]; then + bw login --apikey + else + bw login + fi + fi + + export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw) +fi + +bw sync --session "$BW_SESSION" > /dev/null + +# ── Helper ──────────────────────────────────────────────────────────────────── +get_note() { + bw get item "$1" --session "$BW_SESSION" | jq -r '.notes' +} + +get_password() { + bw get item "$1" --session "$BW_SESSION" | jq -r '.login.password // .notes' +} + +# ── Android ─────────────────────────────────────────────────────────────────── +echo "→ Writing android/app/google-services.json" +get_note "Donetick Google Services Android" > "$REPO_ROOT/android/app/google-services.json" + +echo "→ Writing android keystore" +get_note "Donetick Android Keystore" | base64 --decode > "$REPO_ROOT/android/app/release/donetick.jks" + +KEYSTORE_PASSWORD=$(get_password "Donetick Keystore Password") + +cat > "$REPO_ROOT/android/keystore.properties" < "$REPO_ROOT/android/play-service-account.json" + +# ── iOS ─────────────────────────────────────────────────────────────────────── +echo "→ Writing ios/App/App/GoogleService-Info.plist" +get_note "Donetick Google Services iOS" > "$REPO_ROOT/ios/App/App/GoogleService-Info.plist" + +echo "→ Writing App Store Connect key" +get_note "Donetick App Store Connect Key" | base64 --decode > "$REPO_ROOT/ios/AuthKey_84F695CDQ3.p8" + +# ── Env ─────────────────────────────────────────────────────────────────────── +echo "→ Writing .env.production" +get_note "Donetick Env Production" > "$REPO_ROOT/.env.production" + +echo "✓ All secrets pulled successfully" diff --git a/scripts/push-secrets.sh b/scripts/push-secrets.sh new file mode 100755 index 0000000..5b496ee --- /dev/null +++ b/scripts/push-secrets.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# One-time script to upload local secrets into Vaultwarden. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# BW_SERVER="${BW_SERVER:-https://www.bitwareden.com}" + +# ── Auth ────────────────────────────────────────────────────────────────────── +# bw config server "$BW_SERVER" +# bw login +export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw) + +# ── Helper ──────────────────────────────────────────────────────────────────── +upsert_secure_note() { + local name="$1" + local content="$2" + local existing_id + existing_id=$(bw list items --session "$BW_SESSION" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id' | head -1) + + if [[ -n "$existing_id" ]]; then + bw get item "$existing_id" --session "$BW_SESSION" \ + | jq --arg c "$content" '.notes = $c' \ + | bw encode \ + | bw edit item "$existing_id" --session "$BW_SESSION" > /dev/null + echo " ✓ Updated: $name" + else + bw get template item --session "$BW_SESSION" \ + | jq --arg n "$name" --arg c "$content" \ + '.name = $n | .type = 2 | .secureNote = {"type":0} | .notes = $c' \ + | bw encode \ + | bw create item --session "$BW_SESSION" > /dev/null + echo " ✓ Created: $name" + fi +} + +# ── Upload ──────────────────────────────────────────────────────────────────── +echo "→ Uploading Android google-services.json" +upsert_secure_note \ + "Donetick Google Services Android" \ + "$(cat "$REPO_ROOT/android/app/google-services.json")" + +echo "→ Uploading Fastline google-services.json" +upsert_secure_note \ + "Donetick Google Play Service Account" \ + "$(cat "$REPO_ROOT/donetick-5f910-5688a280a65a--fastline.json")" + +echo "→ Uploading Android keystore (base64)" +upsert_secure_note \ + "Donetick Android Keystore" \ + "$(base64 < /Users/mohamad-macbook-air/donetick-android-ley)" + +echo "→ Uploading iOS GoogleService-Info.plist" +upsert_secure_note \ + "Donetick Google Services iOS" \ + "$(cat "$REPO_ROOT/ios/App/App/GoogleService-Info.plist")" + +echo "→ Uploading App Store Connect key (base64)" +upsert_secure_note \ + "Donetick App Store Connect Key" \ + "$(base64 < /Users/mohamad-macbook-air/Downloads/AuthKey_84F695CDQ3.p8)" + +echo "→ Uploading .env.production" +upsert_secure_note \ + "Donetick Env Production" \ + "$(cat "$REPO_ROOT/.env.production")" + +echo "" +echo "✓ All secrets uploaded. Verify in Vaultwarden, then you can safely delete local copies outside the repo." +echo " NOTE: 'Donetick Keystore Password' should already exist — if not, create it manually as a Login item." diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index 6fcc744..49a8765 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { apiClient } from '../utils/ApiClient' +import { offlineDB } from '../utils/OfflineDB' import { clearAllTokens, saveTokens } from '../utils/TokenStorage' const AuthContext = createContext(null) @@ -67,6 +68,12 @@ export const AuthProvider = ({ children }) => { if (userToken) { setToken(userToken) + try { + await offlineDB.clearAll() + } catch (e) { + console.error('Error clearing offline data on login', e) + } + // Use centralized token storage await saveTokens({ accessToken: userToken, diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index d517c91..a945195 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -2,6 +2,7 @@ import { Preferences } from '@capacitor/preferences' import { API_URL } from '../Config' import { networkManager } from '../hooks/NetworkManager' import { logout, RefreshToken } from './Fetcher' +import { offlineDB } from './OfflineDB' import { clearAllTokens, isRefreshTokenExpired, @@ -49,10 +50,7 @@ class ApiClient { const refreshExpired = await isRefreshTokenExpired() if (refreshExpired) { console.log('Refresh token expired, forcing logout') - await clearAllTokens() - if (window.location.pathname !== '/login') { - window.location.href = '/login' - } + await this.handleLogout() return { success: false, error: 'Refresh token expired' } } @@ -135,6 +133,11 @@ class ApiClient { // Helper to avoid repeating cleanup code async handleLogout() { await clearAllTokens() + try { + await offlineDB.clearAll() + } catch (e) { + console.error('Error clearing offline data on logout', e) + } try { await logout() } catch (e) { diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index 914e0e6..1de190c 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -18,6 +18,7 @@ import { API_URL } from '../../Config' import Logo from '../../Logo' import { useResource } from '../../queries/ResourceQueries' import { apiClient } from '../../utils/ApiClient' +import { offlineDB } from '../../utils/OfflineDB' const CONNECTION_TIMEOUT_MS = 8000 @@ -166,6 +167,11 @@ const LoginSettings = () => { } await Preferences.set({ key: 'customServerUrl', value: trimmedURL }) + try { + await offlineDB.clearAll() + } catch (e) { + console.error('Error clearing offline data on server change', e) + } await apiClient.init(true) refetchResource() setStatus('success') diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx index 4964f6f..3ab615a 100644 --- a/src/views/Things/ThingsView.jsx +++ b/src/views/Things/ThingsView.jsx @@ -355,6 +355,31 @@ const ThingsView = () => { }) } + const handleSetThingState = thing => { + UpdateThingState(thing) + .then(result => { + result.json().then(data => { + const currentThings = [...things] + const thingIndex = currentThings.findIndex( + currentThing => currentThing.id === thing.id, + ) + currentThings[thingIndex] = data.res + setThings(currentThings) + showNotification({ + type: 'success', + title: 'Updated', + message: 'Thing state updated successfully', + }) + }) + }) + .catch(error => { + showError({ + title: 'Unable to update thing state', + message: 'An error occurred while updating the thing state', + }) + }) + } + return ( @@ -550,7 +575,7 @@ const ThingsView = () => { setIsShowEditStateModal(false) setCreateModalThing(null) }} - onSave={handleStateChangeRequest} + onSave={handleSetThingState} currentThing={createModalThing} /> )} diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 8f5a08d..dc76c6c 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -25,6 +25,9 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { localAIService } from '../../service/LocalAIService' import { TASK_COLOR } from '../../utils/Colors' +import AdvancedOptionsSection, { + AdvancedOptionsTrigger, +} from './AdvancedOptionsSection' import AssigneePickerField from './AssigneePickerField' import AttachmentPickerField from './AttachmentPickerField' import DueDatePickerField from './DueDatePickerField' @@ -101,6 +104,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) const [deadlineOffset, setDeadlineOffset] = useState(-1) + const [requireApproval, setRequireApproval] = useState(false) + const [completionWindow, setCompletionWindow] = useState(-1) + const [assignStrategy, setAssignStrategy] = useState('keep_last_assigned') + const [isPrivate, setIsPrivate] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) const [dueDateOnly, setDueDateOnly] = useState(null) const [dueTime, setDueTime] = useState(null) const [useCustomTime, setUseCustomTime] = useState(false) @@ -422,6 +430,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { if (dueDateParsed.result) { syncDueDateStates(dueDateParsed.result) dueDateHighlight = dueDateParsed.highlight[0] + } else if (repeat.dueDate) { + syncDueDateStates(repeat.dueDate) } // Create the cleaned sentence by sequentially applying all cleanups @@ -597,6 +607,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { setAssignees([]) setProjectId(getInitialProject()) setDeadlineOffset(-1) + setRequireApproval(false) + setCompletionWindow(-1) + setAssignStrategy('keep_last_assigned') + setIsPrivate(false) + setShowAdvanced(false) setDueDateOnly(null) setDueTime(null) setUseCustomTime(false) @@ -608,26 +623,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { // Handle different assignee scenarios let finalAssignees = assignees let finalAssignedTo = null - let finalAssignStrategy = 'random' + let finalAssignStrategy = assignStrategy if (isAnyoneTask) { - // @Anyone was explicitly used - anyone can do the task finalAssignees = [] finalAssignedTo = null finalAssignStrategy = 'no_assignee' } else if (assignees.length === 0) { - // No assignees and no @Anyone - fallback to current user finalAssignees = [{ userId: userProfile?.id }] finalAssignedTo = userProfile?.id - finalAssignStrategy = 'keep_last_assigned' - } else if (assignees.length === 1) { - // Single assignee - finalAssignedTo = assignees[0].userId - finalAssignStrategy = 'keep_last_assigned' + finalAssignStrategy = assignStrategy } else { - // Multiple assignees - finalAssignedTo = null - finalAssignStrategy = 'random' + finalAssignedTo = assignees[0].userId + finalAssignStrategy = assignStrategy } const chore = { @@ -642,6 +650,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { priority: priority ? Number(priority) : 0, points: points > -1 ? points : null, deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset, + completionWindow: + completionWindow < 0 || !dueDate ? null : completionWindow, + requireApproval: requireApproval, + isPrivate: isPrivate, status: 0, frequencyType: 'once', frequencyMetadata: {}, @@ -713,7 +725,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { footer={ { }} customRenderer={renderedParts} onEnterPressed={handleEnterPressed} + onShiftEnterPressed={() => { + if (!hasDescription) { + setHasDescription(true) + } + setTimeout(() => richTextEditorRef.current?.focus(), 50) + }} suggestions={{ '#': { value: 'id', @@ -917,15 +934,21 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { /> { - if (!userId) { + values={assignees.map(a => a.userId)} + isAnyone={isAnyoneTask} + onChange={userIds => { + if (userIds.includes('anyone')) { + setIsAnyoneTask(true) setAssignees([]) } else { - setAssignees([{ userId }]) + setIsAnyoneTask(false) + setAssignees(userIds.map(userId => ({ userId }))) } }} - onClear={() => setAssignees([])} + onClear={() => { + setIsAnyoneTask(false) + setAssignees([]) + }} currentUserId={userProfile?.id} members={circleMembers?.res || []} /> @@ -952,41 +975,100 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { /> - + {!hasDescription && ( )} {!hasSubTasks && ( )} + setShowAdvanced(v => !v)} + activeCount={ + [ + points > -1, + requireApproval, + completionWindow > -1, + deadlineOffset > -1, + ].filter(Boolean).length + } + emptyDisplay={pickerEmptyDisplay} + /> + 1} + hasAssignees={assignees.length > 0} + isPrivate={isPrivate} + onIsPrivateChange={setIsPrivate} + /> + {hasDescription && ( Description: diff --git a/src/views/components/AdvancedOptionsSection.jsx b/src/views/components/AdvancedOptionsSection.jsx new file mode 100644 index 0000000..11801eb --- /dev/null +++ b/src/views/components/AdvancedOptionsSection.jsx @@ -0,0 +1,354 @@ +import { + Add, + Approval, + HourglassTop, + Lock, + MoreHoriz, + People, + Remove, + Timer, +} from '@mui/icons-material' +import { + Box, + Button, + IconButton, + Input, + Option, + Select, + Switch, + Typography, +} from '@mui/joy' + +const STRATEGY_OPTIONS = [ + { value: 'keep_last_assigned', label: 'Keep same assignee' }, + { value: 'random', label: 'Random' }, + { value: 'least_completed', label: 'Least completed' }, + { value: 'round_robin', label: 'Round robin' }, +] + +const FieldRow = ({ label, description, children, onLabelClick }) => ( + + + + {label} + + {description && ( + + {description} + + )} + + + {children} + + +) + +// Trigger button — place this inside the chip/action row +export const AdvancedOptionsTrigger = ({ + open, + onToggle, + activeCount = 0, + emptyDisplay = 'icon-text', +}) => { + const showLabel = emptyDisplay === 'icon-text' || open || activeCount > 0 + + return ( + + + + {activeCount > 0 && ( + + {activeCount} + + )} + + ) +} + +// Panel — place this as a sibling below the description/subtask sections +const AdvancedOptionsSection = ({ + open, + points, + onPointsChange, + requireApproval, + onRequireApprovalChange, + completionWindow, + onCompletionWindowChange, + deadlineOffset, + onDeadlineOffsetChange, + assignStrategy, + onAssignStrategyChange, + isPrivate, + onIsPrivateChange, + hasDueDate, + hasMultipleAssignees, + hasAssignees, +}) => { + const displayPoints = points <= 0 ? 0 : points + + const handleDecrement = () => { + const next = Math.max(0, displayPoints - 1) + onPointsChange(next === 0 ? -1 : next) + } + + const handleIncrement = () => { + onPointsChange(displayPoints + 1) + } + + const handlePointsInput = e => { + const v = parseInt(e.target.value) + if (isNaN(v) || v <= 0) { + onPointsChange(-1) + } else { + onPointsChange(Math.min(v, 9999)) + } + } + + return ( + + + + {/* Points */} + + + + + + + + + + + {/* Require approval */} + onRequireApprovalChange(!requireApproval)} + > + onRequireApprovalChange(e.target.checked)} + /> + + + {/* Privacy */} + onIsPrivateChange(!isPrivate) : undefined + } + > + onIsPrivateChange(e.target.checked)} + /> + + + {/* Assignment strategy — only shown when there are multiple assignees */} + {hasMultipleAssignees && ( + + + + )} + + {/* Completion window and deadline — only when due date set */} + {hasDueDate ? ( + <> + + -1 ? completionWindow : ''} + onChange={e => { + const v = parseInt(e.target.value) + onCompletionWindowChange(isNaN(v) ? -1 : Math.max(0, v)) + }} + endDecorator={ + + hrs + + } + sx={{ width: 96 }} + slotProps={{ input: { min: 0 } }} + /> + + + + -1 ? deadlineOffset : ''} + onChange={e => { + const v = parseInt(e.target.value) + onDeadlineOffsetChange(isNaN(v) ? -1 : Math.max(0, v)) + }} + endDecorator={ + + hrs + + } + sx={{ width: 96 }} + slotProps={{ input: { min: 0 } }} + /> + + + ) : ( + + Set a due date to configure completion window and deadline. + + )} + + + + ) +} + +export default AdvancedOptionsSection diff --git a/src/views/components/BaseOptionPicker.jsx b/src/views/components/BaseOptionPicker.jsx index c176020..bac0434 100644 --- a/src/views/components/BaseOptionPicker.jsx +++ b/src/views/components/BaseOptionPicker.jsx @@ -23,6 +23,7 @@ const BaseOptionPicker = ({ getItemColor, getTriggerText, onClear, + menuFooter, }) => { const [isOpen, setIsOpen] = useState(false) const buttonRef = useRef(null) @@ -242,6 +243,9 @@ const BaseOptionPicker = ({ ) })} + {menuFooter && ( + 0 ? 0.5 : 0 }}>{menuFooter} + )} diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js index 8e5cb4e..df9a5d1 100644 --- a/src/views/components/CustomParsers.js +++ b/src/views/components/CustomParsers.js @@ -412,14 +412,37 @@ export const parseRepeatV2 = inputSentence => { } case 'day_of_the_month:every': - result.frequency = parseInt(match[1], 10) - result.frequencyMetadata.months = ALL_MONTHS - result.frequencyMetadata.unit = 'days' + const dayOfMonth = parseInt(match[1], 10) + result.frequencyType = 'interval' + result.frequency = 1 + result.frequencyMetadata.unit = 'months' + + // Calculate the next occurrence of this day of the month + const todayEvery = new Date() + let suggestedDueDate = new Date( + todayEvery.getFullYear(), + todayEvery.getMonth(), + dayOfMonth, + 23, + 59, + 0, + ) + if (suggestedDueDate <= todayEvery) { + // Day has already passed this month, move to next month + suggestedDueDate = new Date( + todayEvery.getFullYear(), + todayEvery.getMonth() + 1, + dayOfMonth, + 23, + 59, + 0, + ) + } + return { result, - name: pattern.name - .replace('{day}', result.frequency) - .replace('{months}', result.frequencyMetadata.months.join(', ')), + name: pattern.name.replace('{day}', dayOfMonth), + dueDate: suggestedDueDate.toISOString(), highlight: [ { text: pattern.name, diff --git a/src/views/components/DueDatePickerField.jsx b/src/views/components/DueDatePickerField.jsx index bb0685a..af6ba3a 100644 --- a/src/views/components/DueDatePickerField.jsx +++ b/src/views/components/DueDatePickerField.jsx @@ -231,6 +231,7 @@ const DueDatePickerField = ({ open={isOpen} onClose={() => setIsOpen(false)} title='Due Date' + fullWidth={false} footer={ {hasDueDate && ( @@ -266,7 +267,7 @@ const DueDatePickerField = ({ } > - + {/* Date shortcuts */} { + const [createOpen, setCreateOpen] = useState(false) + const options = labels.map(label => ({ id: label.id, name: label.name, @@ -15,33 +20,55 @@ const LabelsPickerField = ({ })) return ( - item.id} - getItemLabel={item => item.name} - getItemColor={item => item.color} - renderTriggerIcon={() =>