5 Commits

Author SHA1 Message Date
Mo Tarbin
555c9d0774 Release 1.2.37
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-05 02:12:21 -04:00
Mohamad Tarbin
48dbc7a152 Fix button issues, improve layout, and enhance notification handling (#192)
* Fix approval /declien button issue. update action buttons and improve layout

* feat(notifications): enhance notification handling and default templates across components

* fix(sync): improve sync handling to coalesce concurrent requests and prevent lost writes

there is BUG that was very annoying cause by race condition we we update and sync happen as we update and doesn't return the latest added task. this basically fix it
2026-08-05 01:14:39 -04:00
Mohamad Tarbin
784ffbcd00 Fix action button layout and enhance notification handling (#190)
* Fix approval /declien button issue. update action buttons and improve layout

* feat(notifications): enhance notification handling and default templates across components
2026-08-04 21:22:09 -04:00
Mohamad Tarbin
412faf4ad5 Revert "feat(auth): hide password login when disable_password_auth is set (#4…" (#189)
This reverts commit a05f8dc4a4.
2026-08-03 00:39:01 -04:00
Louis-Michel Couture
a05f8dc4a4 feat(auth): hide password login when disable_password_auth is set (#438) (#121)
Thank yWhen the backend resource endpoint reports disable_password_auth, the login
view hides the username/password form, the 'or' divider and the 'Create new
account' button, leaving only the configured SSO button — so SSO-only
instances present a clean login.

Auth options render only once the resource query has settled, so the password
form never flashes before being hidden (no FOUC) on SSO-only instances.

Also drops a dead setUserProfile(user) call in the post-auth effect (the
symbol was removed earlier, leaving a no-undef that broke lint on this file);
the profile now comes from useUserProfile().

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 00:36:05 -04:00
12 changed files with 259 additions and 95 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 57
versionName "1.2.36"
versionCode 58
versionName "1.2.37"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -462,12 +462,12 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.36;
MARKETING_VERSION = 1.2.37;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@@ -485,12 +485,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.36;
MARKETING_VERSION = 1.2.37;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -504,12 +504,12 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.36;
MARKETING_VERSION = 1.2.37;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -527,12 +527,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.36;
MARKETING_VERSION = 1.2.37;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
"version": "1.2.36",
"version": "1.2.37",
"type": "module",
"engines": {
"node": ">=20.0.0",

View File

@@ -63,6 +63,10 @@ function getInternalValue(timing, displayValue) {
const NotificationTemplate = ({
maxNotifications = 5,
// ChoreEdit gates this editor behind its own on/off switch, so the last row
// must stay put — `notification: true` with no templates is not a valid task.
// Consumers that own an empty state themselves pass 0.
minNotifications = 1,
onChange,
value,
showTimeline = true,
@@ -259,7 +263,8 @@ const NotificationTemplate = ({
return next
})
onChange && onChange(updated)
// No direct onChange here: consumers expect { notifications }, and the
// effect below already emits that shape once the state settles.
setShowSaveDefault(true)
}
@@ -307,9 +312,6 @@ const NotificationTemplate = ({
return (
<Box sx={{ mt: 3, mb: 2 }}>
<Typography level={'body-md'} sx={{ mb: 1 }}>
Notification Timeline
</Typography>
<Box
sx={{
display: 'flex',
@@ -642,7 +644,7 @@ const NotificationTemplate = ({
</Select>
<IconButton
onClick={() => removeNotification(idx)}
disabled={notifications.length === 1}
disabled={notifications.length <= minNotifications}
color={'danger'}
size={'sm'}
variant={'soft'}

View File

@@ -99,7 +99,8 @@ export const useChores = (includeArchive = false) => {
queryFn: async () => {
if (isOfflineFeatureEnabled()) {
try {
// Sync from server first (no-op if already syncing or offline)
// Sync from server first (coalesced with any run already in flight,
// so a just-created chore can't be missed by a stale cursor)
if (networkManager.isOnline) {
await syncEngine.sync()
}

View File

@@ -28,6 +28,10 @@ class SyncEngine {
constructor() {
this.isSyncing = false
this.listeners = []
// The run currently in flight, and the single follow-up run queued behind
// it. See sync() for why a follow-up is needed rather than just waiting.
this.inFlight = null
this.queued = null
}
// Register listener for sync state changes
@@ -42,10 +46,38 @@ class SyncEngine {
this.listeners.forEach(cb => cb(state))
}
// Main sync entry point — returns true if sync succeeded, false otherwise
// Main sync entry point — returns true if sync succeeded, false otherwise.
//
// Concurrent callers are coalesced rather than dropped. Returning early while
// another run is in flight used to lose writes: that run's /sync/changes
// request may have been issued *before* the caller's change reached the
// server, so its cursor skips past the change and the caller reads a cache
// that will never contain it until something else triggers a sync. That is
// why a task created from the modal could vanish on the refetch right after
// it was created. Waiting for the in-flight run is not enough for the same
// reason, so callers that arrive mid-run share one follow-up run instead.
async sync() {
if (!isOfflineFeatureEnabled()) return false
if (this.isSyncing) return false
if (this.inFlight) {
if (!this.queued) {
this.queued = this.inFlight
.catch(() => false)
.then(() => {
this.queued = null
return this.sync()
})
}
return this.queued
}
this.inFlight = this._runSync().finally(() => {
this.inFlight = null
})
return this.inFlight
}
async _runSync() {
this.isSyncing = true
this._notify({ syncing: true, error: null })

View File

@@ -5,7 +5,7 @@ import {
Pause,
PlayArrow,
Repeat,
Schedule,
ThumbDown,
ThumbUp,
TimesOneMobiledata,
Toll,
@@ -21,6 +21,7 @@ import {
IconButton,
Typography,
} from '@mui/joy'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands'
@@ -37,16 +38,16 @@ import ChoreActionMenu from '../components/ChoreActionMenu'
import PendingBadge from '../components/PendingBadge'
const ChoreCard = ({
chore,
performers,
sx,
viewOnly,
showActions = true,
onChipClick,
onAction,
// Multi-select props
isMultiSelectMode = false,
isSelected = false,
onAction,
onChipClick,
onSelectionToggle,
performers,
// Multi-select props
showActions = true,
sx,
viewOnly,
}) => {
const { data: userProfile } = useUserProfile()
const { timeFormat } = useLocalization()
@@ -359,27 +360,6 @@ const ChoreCard = ({
justifyContent: 'center',
}}
>
{chore.status === 3 && (
<Chip
variant='soft'
color='neutral'
size='sm'
sx={{
mb: 1,
px: 0.75,
py: 0.5,
minHeight: 56,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 0.25,
}}
>
<Schedule sx={{ fontSize: 16 }} />
<Typography level='body-xs'>Pending</Typography>
</Chip>
)}
{showActions && (
<Box
display='flex'
@@ -420,7 +400,7 @@ const ChoreCard = ({
>
<ThumbUp sx={{ fontSize: 18 }} />
</IconButton>
{/* <IconButton
<IconButton
variant='soft'
color='danger'
onClick={e => {
@@ -429,8 +409,10 @@ const ChoreCard = ({
}}
sx={{
borderRadius: '50%',
minWidth: 40,
height: 40,
width: 50,
minWidth: 50,
height: 50,
flexShrink: 0,
zIndex: 1,
transition: 'all 0.2s ease',
'&:hover': {
@@ -442,7 +424,7 @@ const ChoreCard = ({
}}
>
<ThumbDown sx={{ fontSize: 18 }} />
</IconButton> */}
</IconButton>
</Box>
) : (
<IconButton
@@ -515,6 +497,7 @@ const ChoreCard = ({
</IconButton>
)}
<ChoreActionMenu
variant='plain'
chore={chore}
onCompleteWithNote={() =>
onAction('completeWithNote', chore)
@@ -530,6 +513,16 @@ const ChoreCard = ({
onWriteNFC={() => onAction('writeNFC', chore)}
onNudge={() => onAction('nudge', chore)}
onDelete={() => onAction('delete', chore)}
sx={{
width: 32,
height: 32,
color: 'text.tertiary',
flexShrink: 0,
'&:hover': {
color: 'text.secondary',
bgcolor: 'background.level1',
},
}}
/>
</Box>
)}

View File

@@ -49,6 +49,23 @@ const getTimeFromTemplate = (template, relativeTime) => {
}
return time
}
// Decide whether this device's user should be notified about a chore:
// - assignedTo set -> only that user
// - no assignedTo -> everyone listed in assignees
// - no assignees -> "Anyone" mode, notify the whole circle
const shouldNotifyUser = (chore, userId) => {
if (!userId) {
return false
}
if (chore.assignedTo > 0) {
return chore.assignedTo === userId
}
if (chore.assignees?.length > 0) {
return chore.assignees.some(assignee => assignee.userId === userId)
}
return true
}
const scheduleNotificationFromTemplate = (
chore,
userProfile,
@@ -193,7 +210,8 @@ const scheduleChoreNotification = async (
if (
chore.notification === false ||
chore.nextDueDate === null ||
chore.isActive === false
chore.isActive === false ||
!shouldNotifyUser(chore, userProfile?.id)
) {
continue
}

View File

@@ -45,22 +45,65 @@ import ScanPanel from './ScanToTask/ScanPanel'
import SubTasks from './SubTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel'
// Canonical reminder template shape, shared with NotificationTemplate and
// LocalNotificationScheduler: a signed value plus 'm' | 'h' | 'd'. Negative is
// before due, positive is after, zero is on due.
const DEFAULT_NOTIFICATION_TEMPLATES = [
{ value: -1, unit: 'd' },
{ value: 0, unit: 'm' },
{ value: 1, unit: 'd' },
]
const UNIT_ALIASES = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
}
// Earlier builds stored {value: 1, unit: 'days', type: 'before'}. Nothing reads
// `type`, and the scheduler's unit switch falls through on 'days', so those
// entries fired at the due time (or collided on id) instead of offsetting.
const normalizeTemplate = template => {
const unit = UNIT_ALIASES[template.unit] || template.unit
const value = Number(template.value) || 0
if (!template.type) return { value, unit }
if (template.type === 'ondue') return { value: 0, unit }
return {
value: template.type === 'before' ? -Math.abs(value) : Math.abs(value),
unit,
}
}
const getDefaultNotification = () => {
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
if (storedDefault) {
return JSON.parse(storedDefault)
try {
const parsed = JSON.parse(storedDefault)
if (Array.isArray(parsed)) {
// An empty list is a deliberate "no reminders by default", not a
// missing value — respect it instead of re-seeding.
const normalized = parsed.map(normalizeTemplate)
if (JSON.stringify(normalized) !== storedDefault) {
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(normalized),
)
}
return normalized
}
} catch {
// fall through and reset to the defaults below
}
}
const defaultNotification = [
{ value: 1, unit: 'days', type: 'before' },
{ value: 0, unit: 'minutes', type: 'ondue' },
{ value: 1, unit: 'days', type: 'after' },
]
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(defaultNotification),
JSON.stringify(DEFAULT_NOTIFICATION_TEMPLATES),
)
return defaultNotification
return DEFAULT_NOTIFICATION_TEMPLATES
}
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
@@ -157,6 +200,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
isListening: false,
})
const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false)
// Reminder default the voice cards start from — read once so the array
// identity stays stable across renders of the panel
const voiceDefaultNotificationTemplates = useMemo(
() => getDefaultNotification(),
[],
)
// Same arrangement for the scan panel: it reports the action for its
// current phase and the modal footer renders it
const [scanState, setScanState] = useState({
@@ -558,6 +607,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
if ('priority' in overrides) setPriority(overrides.priority || 0)
if ('frequency' in overrides) setFrequency(overrides.frequency)
if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || [])
if ('notificationMetadata' in overrides) {
setNotificationMetadata(
overrides.notificationMetadata || { templates: [] },
)
}
if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || [])
@@ -812,24 +866,29 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notification: false,
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
draftId: draftId,
}
// Reminders are a Plus feature and only make sense when the user kept at
// least one template; without the flag the backend never schedules them.
const hasReminders =
isPlusAccount(userProfile) && notificationMetadata?.templates?.length > 0
if (frequency) {
chore.frequencyType = frequency.frequencyType
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
}
if (!frequency && dueDate) {
// Use RFC3339/ISO-8601 format expected by backend.
chore.nextDueDate = new Date(dueDate).toISOString()
}
if (hasReminders && (frequency || dueDate)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
@@ -1291,6 +1350,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
userLabels={voiceLabels}
members={voiceMembers}
userProfile={userProfile}
defaultNotificationTemplates={voiceDefaultNotificationTemplates}
onStateChange={setVoiceState}
/>
)}

View File

@@ -135,10 +135,11 @@ const NotificationPickerField = ({
>
<NotificationTemplate
value={value}
minNotifications={0}
onChange={({ notifications }) => {
latestTemplatesRef.current = notifications
}}
showTimeline
showTimeline={false}
/>
</ResponsiveModal>
</>

View File

@@ -6,6 +6,7 @@ import {
GraphicEq,
Lock,
Mic,
NotificationsNone,
Person,
Repeat,
Sell,
@@ -16,9 +17,11 @@ import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useRef, useState } from 'react'
import { TASK_COLOR } from '../../../utils/Colors'
import { isPlusAccount } from '../../../utils/Helpers'
import AssigneePickerField from '../AssigneePickerField'
import DueDatePickerField from '../DueDatePickerField'
import LabelsPickerField from '../LabelsPickerField'
import NotificationPickerField from '../NotificationPickerField'
import PriorityPickerField from '../PriorityPickerField'
import RepeatPickerField from '../RepeatPickerField'
import { parseVoiceTask } from './parseVoiceTask'
@@ -102,7 +105,11 @@ const describeFrequency = f => {
return names[f.frequencyType] || 'Repeats'
}
const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
const buildChips = (
effective,
frequencyLabel,
{ members, currentUserId, canRemind },
) => {
const chips = []
if (effective.dueDate) {
chips.push({
@@ -128,6 +135,21 @@ const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
label: `P${effective.priority}`,
})
}
// Mirrors buildChorePayload: reminders only reach the backend for Plus
// accounts on a task that has something to remind against
const reminderCount = effective.notificationMetadata?.templates?.length || 0
if (
canRemind &&
reminderCount > 0 &&
(effective.dueDate || effective.frequency)
) {
chips.push({
key: 'reminders',
color: 'neutral',
icon: <NotificationsNone sx={{ fontSize: 12 }} />,
label: reminderCount > 1 ? `${reminderCount} reminders` : '1 reminder',
})
}
if (effective.points != null) {
chips.push({
key: 'points',
@@ -184,9 +206,17 @@ const TaskPreviewCard = ({
[segment.text, parseCtx],
)
const overrides = useMemo(() => segment.overrides || {}, [segment.overrides])
// The parser has no notion of reminders, so the account default stands in
// until the card overrides it — same fallback buildChorePayload applies
const effective = useMemo(
() => ({ ...parsed, ...overrides }),
[parsed, overrides],
() => ({
...parsed,
notificationMetadata: {
templates: parseCtx.defaultNotificationTemplates || [],
},
...overrides,
}),
[parsed, overrides, parseCtx.defaultNotificationTemplates],
)
const frequencyLabel =
@@ -329,6 +359,7 @@ const TaskPreviewCard = ({
{expanded && (
<Box
sx={{
mt: 1,
display: 'flex',
flexDirection: 'row',
gap: 1,
@@ -392,6 +423,16 @@ const TaskPreviewCard = ({
onClear={() => onPatch({ labelIds: [] })}
labels={parseCtx.userLabels}
/>
{parseCtx.canRemind && (
<NotificationPickerField
emptyDisplay='icon'
value={effective.notificationMetadata}
onChange={metadata => onPatch({ notificationMetadata: metadata })}
onClear={() =>
onPatch({ notificationMetadata: { templates: [] } })
}
/>
)}
</Box>
)}
</Box>
@@ -412,6 +453,7 @@ const VoicePanel = ({
userLabels = [],
members = [],
userProfile,
defaultNotificationTemplates = [],
onStateChange,
}) => {
const {
@@ -430,8 +472,14 @@ const VoicePanel = ({
const segmentsScrollRef = useRef(null)
const parseCtx = useMemo(
() => ({ userLabels, members, currentUserId: userProfile?.id }),
[userLabels, members, userProfile?.id],
() => ({
userLabels,
members,
currentUserId: userProfile?.id,
canRemind: isPlusAccount(userProfile),
defaultNotificationTemplates,
}),
[userLabels, members, userProfile, defaultNotificationTemplates],
)
const partialParsed = useMemo(

View File

@@ -177,24 +177,33 @@ export const buildChorePayload = (
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notification: false,
notificationMetadata: {},
subTasks: null,
projectId: projectId === 'default' ? null : projectId,
draftId: generateUUID(),
}
// A per-task override from the voice card wins over the account default;
// an override of [] is a deliberate "no reminders", not a missing value.
const templates =
parsed.notificationMetadata?.templates ?? notificationTemplates
// Reminders are a Plus feature; the flag is what makes the backend schedule
// them, so metadata alone is not enough.
const hasReminders = isPlusAccount(userProfile) && templates?.length > 0
if (parsed.frequency) {
chore.frequencyType = parsed.frequency.frequencyType
chore.frequencyMetadata = parsed.frequency.frequencyMetadata
chore.frequency = parsed.frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = { templates: notificationTemplates }
}
}
if (!parsed.frequency && parsed.dueDate) {
chore.nextDueDate = new Date(parsed.dueDate).toISOString()
chore.notificationMetadata = { templates: notificationTemplates }
}
if (hasReminders && (parsed.frequency || parsed.dueDate)) {
chore.notification = true
chore.notificationMetadata = { templates }
}
return chore