Merge pull request #140 from donetick/07-14-bug-fixes

07 14 bug fixes
This commit is contained in:
Mohamad Tarbin
2026-07-14 21:10:11 -04:00
committed by GitHub
13 changed files with 742 additions and 65 deletions

71
scripts/pull-secrets.sh Executable file
View File

@@ -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" <<EOF
storeFile=release/donetick.jks
storePassword=$KEYSTORE_PASSWORD
keyAlias=key0
keyPassword=$KEYSTORE_PASSWORD
EOF
echo "→ Writing android/play-service-account.json"
get_note "Donetick Google Play Service Account" > "$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"

69
scripts/push-secrets.sh Executable file
View File

@@ -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."

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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')

View File

@@ -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 (
<Container maxWidth='md' sx={{ px: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
@@ -550,7 +575,7 @@ const ThingsView = () => {
setIsShowEditStateModal(false)
setCreateModalThing(null)
}}
onSave={handleStateChangeRequest}
onSave={handleSetThingState}
currentThing={createModalThing}
/>
)}

View File

@@ -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={
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
@@ -834,6 +845,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
}}
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 }) => {
/>
<AssigneePickerField
emptyDisplay={pickerEmptyDisplay}
value={assignees?.[0]?.userId || null}
onChange={userId => {
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 }) => {
/>
</Box>
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<Box
sx={{
mt: 1,
display: 'flex',
flexDirection: 'row',
gap: 1.5,
flexWrap: 'wrap',
alignItems: 'center',
}}
>
{!hasDescription && (
<Button
startDecorator={<Add />}
size='sm'
variant='outlined'
color='neutral'
size='md'
onClick={() => setHasDescription(true)}
endDecorator={
showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='E' />
)
}
sx={{
borderRadius: '128px',
minHeight: 40,
px: 1.25,
gap: 1,
transition: 'all 0.25s ease-in-out',
}}
>
Description
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm' sx={{ color: 'inherit' }}>
Description
</Typography>
</Button>
)}
{!hasSubTasks && (
<Button
startDecorator={<Add />}
size='sm'
variant='outlined'
color='neutral'
size='md'
onClick={() => setHasSubTasks(true)}
endDecorator={
showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='J' />
)
}
sx={{
borderRadius: '128px',
minHeight: 40,
px: 1.25,
gap: 1,
transition: 'all 0.25s ease-in-out',
}}
>
Subtasks
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm' sx={{ color: 'inherit' }}>
Subtasks
</Typography>
</Button>
)}
<AdvancedOptionsTrigger
open={showAdvanced}
onToggle={() => setShowAdvanced(v => !v)}
activeCount={
[
points > -1,
requireApproval,
completionWindow > -1,
deadlineOffset > -1,
].filter(Boolean).length
}
emptyDisplay={pickerEmptyDisplay}
/>
</Box>
<AdvancedOptionsSection
open={showAdvanced}
points={points}
onPointsChange={setPoints}
requireApproval={requireApproval}
onRequireApprovalChange={setRequireApproval}
completionWindow={completionWindow}
onCompletionWindowChange={setCompletionWindow}
deadlineOffset={deadlineOffset}
onDeadlineOffsetChange={setDeadlineOffset}
assignStrategy={assignStrategy}
onAssignStrategyChange={setAssignStrategy}
hasDueDate={!!dueDate}
hasMultipleAssignees={assignees.length > 1}
hasAssignees={assignees.length > 0}
isPrivate={isPrivate}
onIsPrivateChange={setIsPrivate}
/>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>

View File

@@ -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 }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
py: 0.75,
}}
>
<Box
sx={{
minWidth: 0,
flex: '1 1 auto',
cursor: onLabelClick ? 'pointer' : undefined,
userSelect: onLabelClick ? 'none' : undefined,
}}
onClick={onLabelClick}
>
<Typography level='body-sm' fontWeight='md'>
{label}
</Typography>
{description && (
<Typography level='body-xs' textColor='text.tertiary' sx={{ mt: 0.25 }}>
{description}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{children}
</Box>
</Box>
)
// 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 (
<Box
sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
>
<Button
size='sm'
variant={open || activeCount > 0 ? 'soft' : 'outlined'}
color='neutral'
onClick={onToggle}
sx={{
minHeight: 40,
borderRadius: '128px',
px: showLabel ? 1.25 : 0.75,
gap: showLabel ? 1 : 0,
transition: 'all 0.25s ease-in-out',
}}
>
<MoreHoriz sx={{ fontSize: 20 }} />
<Typography
level='body-sm'
sx={{
color: 'inherit',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: showLabel ? 120 : 0,
opacity: showLabel ? 1 : 0,
transform: showLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
More
</Typography>
</Button>
{activeCount > 0 && (
<Box
sx={{
position: 'absolute',
top: -6,
right: -8,
width: 16,
height: 16,
borderRadius: '50%',
bgcolor: 'primary.solidBg',
color: 'primary.solidColor',
fontSize: 10,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
{activeCount}
</Box>
)}
</Box>
)
}
// 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 (
<Box
sx={{
display: 'grid',
gridTemplateRows: open ? '1fr' : '0fr',
opacity: open ? 1 : 0,
transition:
'grid-template-rows 0.25s ease-in-out, opacity 0.2s ease-in-out',
}}
>
<Box sx={{ overflow: 'hidden' }}>
<Box
sx={{
mt: 2,
px: 2,
py: 1,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.level1',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Points */}
<FieldRow
label='Points'
description='Award points for completing this task'
>
<IconButton
size='sm'
variant='outlined'
color='neutral'
onClick={handleDecrement}
disabled={displayPoints === 0}
>
<Remove sx={{ fontSize: 16 }} />
</IconButton>
<Input
type='number'
size='sm'
value={displayPoints === 0 ? '' : displayPoints}
placeholder='0'
onChange={handlePointsInput}
sx={{ width: 64 }}
slotProps={{
input: {
min: 0,
max: 9999,
style: { textAlign: 'center' },
},
}}
/>
<IconButton
size='sm'
variant='outlined'
color='neutral'
onClick={handleIncrement}
>
<Add sx={{ fontSize: 16 }} />
</IconButton>
</FieldRow>
{/* Require approval */}
<FieldRow
label='Require approval'
description='Task needs admin sign-off before it can be closed'
onLabelClick={() => onRequireApprovalChange(!requireApproval)}
>
<Switch
size='sm'
checked={requireApproval}
onChange={e => onRequireApprovalChange(e.target.checked)}
/>
</FieldRow>
{/* Privacy */}
<FieldRow
label='Limited visibility'
description={
!hasAssignees
? 'Assign someone to enable limited visibility'
: 'Only you and assignees can see this task'
}
onLabelClick={
hasAssignees ? () => onIsPrivateChange(!isPrivate) : undefined
}
>
<Switch
size='sm'
checked={isPrivate}
disabled={!hasAssignees}
onChange={e => onIsPrivateChange(e.target.checked)}
/>
</FieldRow>
{/* Assignment strategy — only shown when there are multiple assignees */}
{hasMultipleAssignees && (
<FieldRow
label='Assign strategy'
description='How to pick the next assignee each recurrence'
>
<Select
size='sm'
value={assignStrategy}
onChange={(_, v) => onAssignStrategyChange(v)}
sx={{ minWidth: 190 }}
>
{STRATEGY_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</FieldRow>
)}
{/* Completion window and deadline — only when due date set */}
{hasDueDate ? (
<>
<FieldRow
label='Available from'
description='Hours before the due date the task becomes available'
>
<Input
type='number'
size='sm'
placeholder='—'
value={completionWindow > -1 ? completionWindow : ''}
onChange={e => {
const v = parseInt(e.target.value)
onCompletionWindowChange(isNaN(v) ? -1 : Math.max(0, v))
}}
endDecorator={
<Typography level='body-xs' textColor='text.tertiary'>
hrs
</Typography>
}
sx={{ width: 96 }}
slotProps={{ input: { min: 0 } }}
/>
</FieldRow>
<FieldRow
label='Expires after'
description='Hours after the due date when the task can no longer be completed'
>
<Input
type='number'
size='sm'
placeholder='—'
value={deadlineOffset > -1 ? deadlineOffset : ''}
onChange={e => {
const v = parseInt(e.target.value)
onDeadlineOffsetChange(isNaN(v) ? -1 : Math.max(0, v))
}}
endDecorator={
<Typography level='body-xs' textColor='text.tertiary'>
hrs
</Typography>
}
sx={{ width: 96 }}
slotProps={{ input: { min: 0 } }}
/>
</FieldRow>
</>
) : (
<Typography
level='body-xs'
textColor='text.tertiary'
sx={{ my: 0.5, fontStyle: 'italic' }}
>
Set a due date to configure completion window and deadline.
</Typography>
)}
</Box>
</Box>
</Box>
)
}
export default AdvancedOptionsSection

View File

@@ -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 = ({
</Button>
)
})}
{menuFooter && (
<Box sx={{ mt: items.length > 0 ? 0.5 : 0 }}>{menuFooter}</Box>
)}
</Sheet>
</ClickAwayListener>
</Popper>

View File

@@ -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,

View File

@@ -231,6 +231,7 @@ const DueDatePickerField = ({
open={isOpen}
onClose={() => setIsOpen(false)}
title='Due Date'
fullWidth={false}
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasDueDate && (
@@ -266,7 +267,7 @@ const DueDatePickerField = ({
</Box>
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
{/* Date shortcuts */}
<Typography
level='body-xs'

View File

@@ -1,4 +1,7 @@
import { Label } from '@mui/icons-material'
import { Add, Label } from '@mui/icons-material'
import { Button } from '@mui/joy'
import { useState } from 'react'
import LabelModal from '../Modals/Inputs/LabelModal'
import BaseOptionPicker from './BaseOptionPicker'
const LabelsPickerField = ({
@@ -8,6 +11,8 @@ const LabelsPickerField = ({
labels = [],
emptyDisplay = 'icon-text',
}) => {
const [createOpen, setCreateOpen] = useState(false)
const options = labels.map(label => ({
id: label.id,
name: label.name,
@@ -15,33 +20,55 @@ const LabelsPickerField = ({
}))
return (
<BaseOptionPicker
items={options}
multiple
values={values}
onValuesChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Labels'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<Label
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) => {
if (isEmpty) return 'Labels'
if (selectedItems.length === 1) return selectedItems[0].name
return `${selectedItems.length} labels`
}}
menuMinWidth={220}
/>
<>
<BaseOptionPicker
items={options}
multiple
values={values}
onValuesChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Labels'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<Label
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) => {
if (isEmpty) return 'Labels'
if (selectedItems.length === 1) return selectedItems[0].name
return `${selectedItems.length} labels`
}}
menuMinWidth={220}
menuFooter={
<Button
size='sm'
variant='plain'
color='neutral'
startDecorator={<Add sx={{ fontSize: '16px' }} />}
onClick={e => {
e.stopPropagation()
setCreateOpen(true)
}}
sx={{ width: '100%', justifyContent: 'flex-start' }}
>
Create label
</Button>
}
/>
<LabelModal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
label={null}
/>
</>
)
}

View File

@@ -52,6 +52,7 @@ const SmartTaskTitleInput = ({
onChange,
suggestions,
onEnterPressed,
onShiftEnterPressed,
customRenderer,
isNativeScanner,
onScanClick,
@@ -158,7 +159,11 @@ const SmartTaskTitleInput = ({
} else {
if (e.key === 'Enter') {
e.preventDefault()
if (onEnterPressed) {
if (e.shiftKey) {
if (onShiftEnterPressed) {
onShiftEnterPressed(value)
}
} else if (onEnterPressed) {
onEnterPressed(value)
}
}