38 Commits

Author SHA1 Message Date
Mohamad Tarbin
631d273b02 Merge pull request #174 from scottanderson/eslint3
Pre-commit hooks and CI enforcement for eslint and prettier
2026-08-16 16:32:25 -04:00
Scott Anderson
d4b21f8f4e Enable CI enforcement and pre-commit hooks 2026-08-16 15:51:14 -04:00
Mohamad Tarbin
a407606f1c Merge pull request #235 from donetick/eslint3
ran npx prettier -w .
2026-08-16 15:38:12 -04:00
Mohamad Tarbin
4f21242e39 ran npx prettier -w . 2026-08-16 15:35:44 -04:00
Mohamad Tarbin
2a4f085e18 Merge pull request #234 from donetick/eslint3
ran npm run lint:fix
2026-08-16 15:33:13 -04:00
Mohamad Tarbin
292597e7c6 ran npm run lint:fix 2026-08-16 11:52:40 -04:00
Mohamad Tarbin
e377fb5b06 Merge pull request #233 from donetick/0814-fixes
0814 fixes
2026-08-16 11:32:06 -04:00
Mohamad Tarbin
84f86032c9 Merge pull request #231 from everysingletear/fix/activities-card-scope
fix: ActivitiesCard crashes — t() in a prop default has no scope
2026-08-16 11:27:12 -04:00
Mo Tarbin
834db6b39d Merge remote-tracking branch 'origin/develop' into 0814-fixes
# Conflicts:
#	public/locales/en/labels.json
#	src/views/Chores/MyChores.jsx
#	src/views/Chores/components/ChoreToolbarPrototype.jsx
#	src/views/Chores/components/MultiSelectToolbar.jsx
#	src/views/Chores/hooks/useChoreActions.js
2026-08-16 02:59:24 -04:00
Mo Tarbin
97453803d6 feat: add new view options and update keyboard shortcuts for improved navigation 2026-08-16 02:53:53 -04:00
Mo Tarbin
1c99bd1886 feat: enhance chore management with new modals and filter options 2026-08-16 02:46:04 -04:00
Mo Tarbin
5359ae193b add SortAndFilterMenu component and integrate it into various views for enhanced sorting and filtering capabilities 2026-08-16 02:16:33 -04:00
Mo Tarbin
325d5e6df7 fix: update assignee filter logic and improve display options in MyChores and ChoreToolbarPrototype 2026-08-16 02:07:44 -04:00
Mo Tarbin
79d92ca8c9 add quick actions for creating labels, projects, and filters in GlobalSearchPalette
refactor: move stripHtml function to Helpers utility

fix: update chore filters to include raw description for better search indexing
enhance: implement search parameter handling for modal openings in ProjectView and LabelView
2026-08-16 01:36:20 -04:00
Mo Tarbin
2480049fd1 Improve user feedback and modal behavior in ErrorReportModal and PolicyUpdateModal 2026-08-16 01:26:28 -04:00
Mo Tarbin
5c71e4ce58 fix: handle manual bug reports by modifying error structure in submitErrorReport 2026-08-16 01:07:55 -04:00
Mo Tarbin
77834aa3c0 fix: add z-index to AppModal handle as ReportIssue modal X was not pressable 2026-08-16 00:32:14 -04:00
Mo Tarbin
76fb3500ec fix: update PolicyUpdateModal to open documents in system browser 2026-08-16 00:31:47 -04:00
Mo Tarbin
2d5dd81371 Fix : https://github.com/donetick/donetick/issues/794 2026-08-15 21:36:56 -04:00
Mo Tarbin
c6f7ce48d8 feat: enhance chore actions with bulk operations and new label detail view
- Refactored bulk operations in useChoreActions to streamline completion, archiving, deletion, and other actions.
- Introduced new hooks for managing local chore state during bulk operations.
- Added handleBulkDueDate, handleBulkAssignee, handleBulkPriority, and handleBulkLabels functions for better task management.
- Implemented a new LabelDetailView component to display and manage tasks associated with a specific label.
- Updated LabelView to navigate to LabelDetailView on label click.
- Improved multi-select functionality to support range selection and summary of selected chores.
- Minor UI adjustments and text updates in AdvancedOptionsSection for clarity.
2026-08-15 12:33:39 -04:00
everysingletear
64aca507af fix: ChoresOverview does not parse — hook inserted into an object literal
`CHORE_STATUS` is a module-level object literal, and a `useTranslation`
call ended up inside it:

    const CHORE_STATUS = {
      const { t } = useTranslation('chores')
      NO_DUE_DATE: 'No due date',

That is a syntax error, so the file cannot be parsed at all. It does not
break the build because nothing imports `ChoresOverview` — vite never
compiles it — but it breaks eslint, editors, and anything else that walks
the whole tree.

The hook moves into the component, which is where the file's `t()` calls
actually live.

This one arrived with #210, the same way #216 brought the ActivitiesCard
crash: my tooling wrote the hook in mechanically and nothing downstream
parsed the result. Both are now covered by the `no-undef` pass I described
in the other commit — it reports the parse error too.
2026-08-15 17:02:02 +08:00
everysingletear
d813971809 fix: ActivitiesCard crashes — t() in a prop default has no scope
`ActivitiesCard` takes its title as `({ title = t('activity.title') })`.
A default parameter is evaluated in the function's own scope, where `t`
does not exist: the only `t` in the file is bound inside `ActivityItem`,
a separate component. `Sidepanel` renders the card without a `title`, and
`activities` is `enabled: true` in `DEFAULT_SIDEPANEL_CONFIG`, so the
default is always evaluated on a desktop-width screen.

The hook moves into the component body and the title falls back there
instead. Same rendered output; three render sites now read `displayTitle`.

This is my regression: it arrived with #216, which described itself as
"no behaviour change". It was not caught because a bundler cannot flag it
— an unbound `t` is indistinguishable from a global — and my checks only
verified that keys and imports existed, not that `t` was in scope. It is
visible in the built bundle: `({title:e=t("activity.title")})` keeps the
literal `t` because the minifier cannot rename a free variable, while a
working call nearby minifies to `q=e("common.confirm")`.

I have added an eslint `no-undef` pass to my own pipeline and run it
before sending anything from now on.
2026-08-15 17:01:18 +08:00
Mo Tarbin
7729917611 enhance label search functionality with Fuse.js integration and improved UI 2026-08-15 00:45:51 -04:00
Mohamad Tarbin
96ba672b09 Merge pull request #210 from everysingletear/i18n/chores
i18n: extract the chore list and its modals (Part of #145)
2026-08-15 00:03:09 -04:00
everysingletear
7d36090e46 i18n: extract the chore list and its modals (chores namespace)
Part of #145.

Twenty-three files across the task list zone: the list and card views,
sorting and grouping, multi-select and its toolbar and help sheet,
archived tasks, the assignee card, the chore action menu, the
nudge/NFC/photo modals, the rich text editor, the scan panel, the
notification templates and the keyboard-shortcut toasts.

Extends the existing `chores` namespace, so `src/i18n/config.js` is
untouched.

English only — no translations, no behaviour change. Every t() value is
checked against this branch's base: the string must appear
character-for-character in the code it replaces (247 call sites).

Two values are matched loosely and worth naming: archived.closeMultiSelect
in both the archived view and the toolbar. The base builds that tooltip as
`${size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`, so only one branch
of the ternary exists contiguously in the source. Both keys hold exactly
what each branch renders; the tooltip is kept whole so a translator can
reorder it.

Rebased on current `develop` again after #215 and #216 landed — the
dictionary conflict was theirs, not the code's. No code file in this PR
was touched upstream in the meantime.
2026-08-15 09:03:28 +08:00
Mohamad Tarbin
f43369c960 Merge pull request #229 from donetick/revert-175-enable-dependabot
Revert "Enable dependabot"
2026-08-14 18:56:30 -04:00
Mohamad Tarbin
64fa636413 Revert "Enable dependabot" 2026-08-14 18:56:17 -04:00
Mohamad Tarbin
c1f6431174 Merge pull request #175 from scottanderson/enable-dependabot
Enable dependabot
2026-08-14 18:48:46 -04:00
Mohamad Tarbin
537ab0568b Merge pull request #216 from everysingletear/i18n/regen
i18n: extract the remaining task, history, filter and timer screens (Part of #145)
2026-08-14 18:41:00 -04:00
Mohamad Tarbin
b095f26d61 Merge branch 'develop' into i18n/regen 2026-08-14 18:40:51 -04:00
Mohamad Tarbin
2d776e973a Merge pull request #215 from everysingletear/i18n/leftovers
i18n: extract the strings left behind in already-localized screens (Part of #145)
2026-08-14 18:40:07 -04:00
Mo Tarbin
ddb726ad1f Bump version to 1.2.47
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-14 08:59:25 -04:00
Mohamad Tarbin
67593ec284 Merge pull request #221 from donetick/0813-fixes
0813 fixes
2026-08-14 08:58:34 -04:00
Mo Tarbin
6bc5982294 fix: update key prop in SelectModal options to use item.id for consistency 2026-08-14 08:57:53 -04:00
Mo Tarbin
1dd01e0eca Fix: Fixing Select start render below the modal make it rendioner in modal but keep the modal small so completely overlap with it. fixing that by applying custom global class 2026-08-14 08:55:25 -04:00
everysingletear
ede1a11099 i18n: extract the remaining task, history, filter and timer screens
Part of #145.

Sixteen files that were left out of my earlier PRs because my branch also
carried unrelated changes in them. Those are stripped here: each file is
your current `develop` version with the string extraction applied on top,
nothing else.

Covered: the chore action hook and its toasts, activities and smart-insight
cards, the chore toolbar, chore history and its card, saved filters, the
timer details view, project and label modals, the notification picker,
the pending badge, the sync status indicator, the SSE settings and hook,
and the profile avatar menu.

All namespaces already exist, so `src/i18n/config.js` is untouched.
Keys added: 83 `chores`, 31 `common`, 10 `timer`, 9 `history`, 6 `labels`,
5 `projects`, 2 `filters`, 1 `settings`.

English only — no translations, no behaviour change. Every t() value is
checked to appear character-for-character in the code it replaces, or to
match the value already in your dictionary for the same key: 221 call
sites, no mismatches.

Five files from the same batch are deliberately left out. They build
translated labels in module-level constant tables, where the hook cannot
be called — `FilterBar`, `RepeatSection`, `RepeatPickerField`,
`FilterBuilderContent` and `AdvancedOptionsSection`. Those need the key
to travel as data and be resolved inside the component, which is a design
change rather than an extraction, so it deserves its own PR.
2026-08-14 11:26:24 +08:00
everysingletear
6e97c70570 i18n: extract the strings left behind in already-localized screens
Part of #145.

Two files you localized yourself, each with a handful of strings that the
pass didn't reach:

- `ChoreView` — the offline-sync notices ("You're offline — completion
  will sync when back online" and its skip/start/pause/restore siblings)
  and the error toasts they fall back to. 12 keys added to `en/chores.json`.
- `MFASettings` — the "Generate New Codes" button. 1 key added to
  `en/settings.json`.

Both extend namespaces that already exist, so `src/i18n/config.js` is
untouched and this cannot conflict with my other open PRs.

English only — no translations, no behaviour change. Every t() value is
checked to appear character-for-character in the code it replaces, or to
match the value already in your dictionary for the same key (126 call
sites total across both files).
2026-08-14 11:10:35 +08:00
Scott Anderson
dfa07aa5d1 dependabot 2026-07-28 22:44:36 -04:00
226 changed files with 6162 additions and 2075 deletions

View File

@@ -9,13 +9,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'
- name: Install dependencies
run: npm i
- run: npm run build
- run: npm run lint:ci
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'
- name: Install dependencies
run: npm i
- run: npm run build
- run: npm run lint:ci

View File

@@ -12,15 +12,16 @@ Builds and (optionally) uploads signed Android and iOS release artifacts from th
Common flags on `release.sh`:
| Flag | Effect |
|---|---|
| `--android` / `--ios` | Build only one platform |
| `--bump minor\|major` | Bump type (default: `patch`) |
| `--skip-bump` | Build with the current version, don't bump |
| `--upload` | Upload after building |
| `--track TRACK` | Play Store track for `--upload` (default: `internal`) |
| Flag | Effect |
| --------------------- | ----------------------------------------------------- |
| `--android` / `--ios` | Build only one platform |
| `--bump minor\|major` | Bump type (default: `patch`) |
| `--skip-bump` | Build with the current version, don't bump |
| `--upload` | Upload after building |
| `--track TRACK` | Play Store track for `--upload` (default: `internal`) |
Outputs:
- Android: `android/app/build/outputs/bundle/release/app-release.aab`
- iOS: `build/ios/Donetick.ipa`
@@ -36,15 +37,15 @@ Outputs:
All secrets live in Vaultwarden (`https://www.bitwarden.com`) as Secure Notes, named exactly as below:
| Vault item name | Written to | Encoding |
|---|---|---|
| Donetick Google Services Android | `android/app/google-services.json` | raw |
| Donetick Android Keystore | `android/app/release/donetick.jks` | base64 |
| Donetick Keystore Password | (used inline for `android/keystore.properties`) | raw |
| Donetick Google Play Service Account | `android/play-service-account.json` | raw |
| Donetick Google Services iOS | `ios/App/App/GoogleService-Info.plist` | raw |
| Donetick App Store Connect Key | `ios/AuthKey_84F695CDQ3.p8` | base64 |
| Donetick Env Production | `.env.production` | raw |
| Vault item name | Written to | Encoding |
| ------------------------------------ | ----------------------------------------------- | -------- |
| Donetick Google Services Android | `android/app/google-services.json` | raw |
| Donetick Android Keystore | `android/app/release/donetick.jks` | base64 |
| Donetick Keystore Password | (used inline for `android/keystore.properties`) | raw |
| Donetick Google Play Service Account | `android/play-service-account.json` | raw |
| Donetick Google Services iOS | `ios/App/App/GoogleService-Info.plist` | raw |
| Donetick App Store Connect Key | `ios/AuthKey_84F695CDQ3.p8` | base64 |
| Donetick Env Production | `.env.production` | raw |
None of these files are committed to git — all covered by `.gitignore`.
@@ -57,6 +58,7 @@ The App target uses **manual signing for Release**, not Automatic. This was a de
- `fastlane/Fastfile`'s `ios release` lane passes explicit `export_options: { signingStyle: "manual", provisioningProfiles: {...} }` to `build_app` — do **not** switch this back to `-allowProvisioningUpdates`/automatic without a good reason, it re-triggers the ambiguous cert selection.
**If you ever add/change an entitlement** (new Capability in Signing & Capabilities, e.g. a new permission), the existing provisioning profile becomes stale and exports will fail with an error like `"App.app" requires a provisioning profile with the X feature`. Fix:
1. developer.apple.com → Identifiers → `com.donetick.app` → confirm the new capability is checked
2. developer.apple.com → Profiles → find `Donetick App Store(fastline)` → regenerate it → download
3. Install it locally: get its UUID (`security cms -D -i <file>.mobileprovision | plutil -extract UUID xml1 -o - -`) and copy to `~/Library/MobileDevice/Provisioning Profiles/<uuid>.mobileprovision`

View File

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

View File

@@ -21,17 +21,13 @@
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
"baselineProfiles": ["baselineProfiles/1/app-release.dm"]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
"baselineProfiles": ["baselineProfiles/0/app-release.dm"]
}
],
"minSdkVersionForDexing": 28
}
}

View File

@@ -1 +1 @@
{"version":1,"sessions":{}}
{ "version": 1, "sessions": {} }

View File

@@ -1,7 +1,7 @@
# Crowdin configuration for Donetick
# See: https://support.crowdin.com/configuration-file/
project_id: "donetick"
project_id: 'donetick'
api_token_env: CROWDIN_API_TOKEN
preserve_hierarchy: true

View File

@@ -6,7 +6,9 @@ const browser = await chromium.launch()
const context = await browser.newContext({ storageState: state })
const page = await context.newPage()
page.on('console', msg => console.log('[console]', msg.type(), msg.text()))
page.on('pageerror', err => console.log('[pageerror]', err.message, '\n', err.stack))
page.on('pageerror', err =>
console.log('[pageerror]', err.message, '\n', err.stack),
)
await page.goto('http://localhost:5173/chores/create')
await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now())

View File

@@ -1,4 +1,4 @@
import { test as base, expect } from '@playwright/test'
import { expect, test as base } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -9,7 +9,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
* After successful signup the app auto-logs in and walks through the
* onboarding flow (/circle-setup, then /ready) before landing on /chores.
*/
export async function signUpViaUI(page, { username, email, password, displayName }) {
export async function signUpViaUI(
page,
{ displayName, email, password, username },
) {
await page.goto('/signup')
await page.locator('#username').fill(username)
await page.locator('#email').fill(email)
@@ -30,7 +33,7 @@ export async function signUpViaUI(page, { username, email, password, displayName
* Fill and submit the login form through the UI.
* After successful login the app redirects to /chores.
*/
export async function loginViaUI(page, { username, password }) {
export async function loginViaUI(page, { password, username }) {
await page.goto('/login')
await page.locator('#username').fill(username)
await page.locator('#password').fill(password)
@@ -43,12 +46,12 @@ export async function loginViaUI(page, { username, password }) {
* shared E2E user (via persisted storage state, no UI interaction required).
*/
export const test = base.extend({
authenticatedPage: async ({ browser }, use) => {
authenticatedPage: async ({ browser }, callback) => {
const ctx = await browser.newContext({
storageState: path.join(__dirname, '..', '.auth', 'state.json'),
})
const page = await ctx.newPage()
await use(page)
await callback(page)
await ctx.close()
},
})

View File

@@ -1,4 +1,4 @@
import { writeFile, mkdir } from 'fs/promises'
import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -56,7 +56,7 @@ export default async function globalSetup() {
throw new Error(`Login failed (${loginRes.status}): ${body}`)
}
const { token, expire } = await loginRes.json()
const { expire, token } = await loginRes.json()
// Write a Playwright storage-state file containing the token in localStorage
const stateDir = path.join(__dirname, '.auth')
@@ -84,7 +84,11 @@ export default async function globalSetup() {
async function waitForServer(url, retries = 20, delayMs = 1000) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(`${url}/api/v1/auth/login`, { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })
const res = await fetch(`${url}/api/v1/auth/login`, {
method: 'POST',
body: '{}',
headers: { 'Content-Type': 'application/json' },
})
if (res.status < 500) return
} catch {
// server not up yet

View File

@@ -1,12 +1,14 @@
import { test, expect } from '@playwright/test'
import { signUpViaUI, loginViaUI } from '../fixtures/auth.js'
import { expect, test } from '@playwright/test'
import { loginViaUI, signUpViaUI } from '../fixtures/auth.js'
// Username must match /^[a-z.-]+$/ — no digits allowed.
// Generate a random lowercase-only suffix for uniqueness across runs.
function randomSuffix(len = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz'
return Array.from({ length: len }, () =>
chars[Math.floor(Math.random() * 26)],
return Array.from(
{ length: len },
() => chars[Math.floor(Math.random() * 26)],
).join('')
}
@@ -41,7 +43,9 @@ test.describe('Auth Sign Up', () => {
test.describe('Auth Login', () => {
// Re-use the shared E2E user that global-setup already created
test('logs in with valid credentials and lands on /chores', async ({ page }) => {
test('logs in with valid credentials and lands on /chores', async ({
page,
}) => {
await loginViaUI(page, {
username: 'e2e.user',
password: 'E2ePassword123!',

View File

@@ -56,6 +56,15 @@ export default [
globals: globals.node,
},
},
{
files: ['e2e/**/*.{js,mjs}'],
languageOptions: {
globals: {
...globals.node,
...globals.browser,
},
},
},
{
files: ['*.config.{mjs,ts}'],
languageOptions: {

View File

@@ -31,8 +31,7 @@ Build a signed, App Store-ready .ipa
Upload the built .ipa to TestFlight
----
---
## Android
@@ -60,7 +59,7 @@ Build a signed release .apk (for sideloading/testing)
Upload the built .aab to the Play Store (internal testing track by default)
----
---
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View File

@@ -4,4 +4,4 @@
"capacitor": {}
},
"type": "custom"
}
}

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 = 67;
CURRENT_PROJECT_VERSION = 68;
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.46;
MARKETING_VERSION = 1.2.47;
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 = 67;
CURRENT_PROJECT_VERSION = 68;
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.46;
MARKETING_VERSION = 1.2.47;
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 = 67;
CURRENT_PROJECT_VERSION = 68;
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.46;
MARKETING_VERSION = 1.2.47;
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 = 67;
CURRENT_PROJECT_VERSION = 68;
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.46;
MARKETING_VERSION = 1.2.47;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -11,4 +11,4 @@
"author": "xcode",
"version": 1
}
}
}

View File

@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"info": {
"version": 1,
"author": "xcode"
}
}
}

View File

@@ -53,4 +53,4 @@
"version": 1,
"author": "xcode"
}
}
}

562
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "donetick",
"version": "1.2.45",
"version": "1.2.47",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "donetick",
"version": "1.2.45",
"version": "1.2.47",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/in-app-review": "^8.0.0",
@@ -106,6 +106,7 @@
"eslint-plugin-sort-destructure-keys": "^3.0.0",
"eslint-plugin-tailwindcss": "^3.18.3",
"husky": "^8.0.3",
"lint-staged": "^16.4.0",
"patch-package": "^8.0.1",
"postcss": "^8.4.32",
"prettier": "^3.8.3",
@@ -6268,6 +6269,22 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"license": "MIT",
@@ -7075,6 +7092,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
@@ -7088,6 +7121,115 @@
"node": ">=4"
}
},
"node_modules/cli-truncate": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
"integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^8.0.0",
"string-width": "^8.2.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/cli-truncate/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cli-truncate/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/slice-ansi": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.3",
"is-fullwidth-code-point": "^5.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/cli-truncate/node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"dev": true,
@@ -8101,6 +8243,19 @@
"node": ">=6"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"license": "MIT",
@@ -9169,6 +9324,19 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
@@ -10645,6 +10813,172 @@
"version": "1.2.4",
"license": "MIT"
},
"node_modules/lint-staged": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^14.0.3",
"listr2": "^9.0.5",
"picomatch": "^4.0.3",
"string-argv": "^0.3.2",
"tinyexec": "^1.0.4",
"yaml": "^2.8.2"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/lint-staged/node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/lint-staged/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/lint-staged/node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
"eventemitter3": "^5.0.1",
"log-update": "^6.1.0",
"rfdc": "^1.4.1",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/listr2/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/listr2/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/listr2/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/listr2/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/listr2/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/listr2/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/load-json-file": {
"version": "4.0.0",
"dev": true,
@@ -10757,6 +11091,144 @@
"version": "4.1.1",
"license": "MIT"
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
"cli-cursor": "^5.0.0",
"slice-ansi": "^7.1.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/log-update/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/log-update/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/log-update/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/log-update/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/log-update/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"license": "MIT",
@@ -11014,6 +11486,19 @@
"node": ">=8"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"devOptional": true,
@@ -11528,6 +12013,22 @@
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/open": {
"version": "8.4.2",
"dev": true,
@@ -13376,6 +13877,36 @@
"node": ">=4"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/restore-cursor/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"license": "MIT",
@@ -13384,6 +13915,13 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rimraf": {
"version": "6.0.1",
"dev": true,
@@ -13985,6 +14523,16 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"license": "MIT",
@@ -14732,6 +15280,16 @@
"version": "1.3.3",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
"version": "1.2.46",
"version": "1.2.47",
"type": "module",
"engines": {
"node": ">=20.0.0",
@@ -21,7 +21,7 @@
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
"lint": "eslint && prettier -c .",
"lint:ci": "true # TODO: eslint -f gha && prettier -c .",
"lint:ci": "eslint -f gha && prettier -c .",
"lint:fix": "eslint --fix && prettier -w .",
"preview": "vite preview",
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
@@ -138,6 +138,7 @@
"eslint-plugin-sort-destructure-keys": "^3.0.0",
"eslint-plugin-tailwindcss": "^3.18.3",
"husky": "^8.0.3",
"lint-staged": "^16.4.0",
"patch-package": "^8.0.1",
"postcss": "^8.4.32",
"prettier": "^3.8.3",

View File

@@ -8,4 +8,4 @@
}
]
}
}
}

View File

@@ -63,6 +63,8 @@
"skip": "Skip",
"cancel": "Cancel",
"noPriority": "No Priority",
"more": "More",
"changeDueDate": "Change due date",
"subtasks": "Subtasks",
"noDescription": "No description available",
"timer": {
@@ -70,12 +72,338 @@
"paused": "Timer Paused",
"reset": "Reset Timer",
"delete": "Delete Session"
}
},
"unableUndo": "Failed to undo",
"offlineComplete": "You're offline — completion will sync when back online",
"unableComplete": "Unable to complete task",
"offlineSkip": "You're offline — skip will sync when back online",
"unableSkip": "Unable to skip task",
"offlineStart": "You're offline — start will sync when back online",
"unableStart": "Unable to start task",
"offlinePause": "You're offline — pause will sync when back online",
"unablePause": "Unable to pause task",
"offlineRestore": "You're offline — restore will sync when back online",
"restoreFailed": "Failed to restore",
"unableRestore": "Unable to restore task"
},
"toolbar": {
"defaultProject": "Default Project"
"defaultProject": "Default Project",
"everyone": "Everyone",
"mine": "Mine",
"availableToMe": "Available to me",
"others": "Others",
"cards": "Cards",
"compact": "Compact",
"calendar": "Calendar",
"filters": "Filters",
"viewGroup": "View & Group",
"filterNamePlaceholder": "Filter name…",
"clearAll": "Clear all",
"saveFilter": "Save Filter",
"saveAsNew": "Save as New Filter",
"groupBy": "Group by",
"showTasksFor": "Show tasks for",
"type": {
"assignee": "Assignee"
},
"display": "Display",
"displayOptions": "Display options"
},
"sort": {
"assignedToMe": "Assigned to me"
"assignedToMe": "Assigned to me",
"smart": "Smart",
"createdDate": "Created Date",
"updatedDate": "Updated Date",
"sortAndGroup": "Sort and Group (Ctrl+G)",
"groupBy": "Group By",
"quickFilters": "Quick Filters",
"assignedTo": "Assigned to:",
"availableForMe": "Available for me",
"assignedToOthers": "Assigned to others",
"createFilter": "Create Filter",
"createFilterHint": "Build advanced filter rules"
},
"thing": {
"triggerHint": "Trigger a task when a thing state changes to a desired state",
"selectThing": "Select a thing",
"enterText": "Enter the text to trigger the task"
},
"activity": {
"showMore": "Show more",
"title": "Recent Activities",
"loading": "Loading activities...",
"status": {
"done": "Done"
}
},
"group": {
"overdue": "Overdue",
"dueToday": "Due Today",
"pendingApproval": "Pending Approval",
"dueThisWeek": "Due This Week",
"noDueDate": "No Due Date",
"dueDate": "Due Date",
"dueLater": "Due Later"
},
"insights": {
"highPriority": "High Priority"
},
"labels": {
"label": "Labels"
},
"actionMenu": {
"view": "View",
"archive": "Archive",
"writeNFC": "Write to NFC",
"completeWithNote": "Complete with note",
"completeInPast": "Complete in past",
"skipToNext": "Skip to next due date",
"sendNudge": "Send nudge",
"history": "History",
"clone": "Clone",
"moveToProject": "Move to project",
"removeDueDate": "Remove due date"
},
"actions": {
"rescheduledTitle": "Task Rescheduled",
"rescheduledMessage": "The task due date has been updated successfully.",
"unplannedTitle": "Task Unplanned",
"unplannedMessage": "The task is now unplanned and has no due date.",
"archivedTitle": "Task Archived",
"startedTitle": "Task Started",
"startedMessage": "The task has been marked as started.",
"pausedTitle": "Task Paused",
"pausedMessage": "The task has been paused.",
"deletedMessage": "The task has been deleted.",
"undoable": {
"completed": "Task completed"
},
"completionPending": "Task completion pending",
"failCompleteTitle": "Failed to complete",
"failStartTitle": "Failed to start",
"failPauseTitle": "Failed to pause",
"failApproveTitle": "Failed to approve",
"failRejectTitle": "Failed to reject",
"deletedMessageLong": "The task has been deleted successfully.",
"failDeleteTitle": "Failed to delete",
"failArchiveTitle": "Failed to archive",
"failSkipTitle": "Failed to skip",
"movedTitle": "Task Moved",
"failMoveTitle": "Failed to move task",
"failRescheduleTitle": "Failed to reschedule",
"nudgeSentTitle": "Nudge Sent!",
"nudgeFailed": "Failed to send nudge",
"failNudgeTitle": "Failed to Send Nudge",
"bulk": {
"completeTitle": "Complete Tasks",
"completedTitle": "✅ Tasks Completed",
"completeFailedTitle": "Bulk Complete Failed",
"archiveTitle": "Archive Tasks",
"archivedTitle": "📦 Tasks Archived",
"archiveFailedTitle": "Bulk Archive Failed",
"deleteTitle": "Delete Tasks",
"skipTitle": "Skip Tasks",
"skippedTitle": "⏭️ Tasks Skipped",
"skipFailedTitle": "Bulk Skip Failed"
}
},
"archived": {
"restoredTitle": "Task Restored",
"restoredMsg": "The task has been restored and is now active.",
"deletedTitle": "Task Deleted",
"delete": "Delete",
"someFailedTitle": "Some Tasks Failed",
"unexpectedError": "An unexpected error occurred. Please try again.",
"deletedBulkTitle": "🗑️ Tasks Deleted",
"bulkDeleteFailTitle": "Bulk Delete Failed",
"archivedDate": "Archived Date",
"loadFailTitle": "Failed to load archived tasks",
"loadFailMsg": "Please try again later.",
"deletedMsg": "The archived task has been permanently deleted.",
"restoreTasksTitle": "Restore Tasks",
"restore": "Restore",
"restoreConfirm_one": "Restore {{count}} task to active list?",
"restoreConfirm_other": "Restore {{count}} tasks to active list?",
"restoredBulkTitle": "📤 Tasks Restored",
"restoredCount_one": "Restored {{count}} task{{offline}}.",
"restoredCount_other": "Restored {{count}} tasks{{offline}}.",
"restoreFailCount_one": "{{count}} task could not be restored.",
"restoreFailCount_other": "{{count}} tasks could not be restored.",
"bulkRestoreFailTitle": "Bulk Restore Failed",
"deleteTasksTitle": "Delete Archived Tasks",
"deleteConfirm_one": "Permanently delete {{count}} archived task?\n\nThis action cannot be undone.",
"deleteConfirm_other": "Permanently delete {{count}} archived tasks?\n\nThis action cannot be undone.",
"deletedCount_one": "Successfully deleted {{count}} task.",
"deletedCount_other": "Successfully deleted {{count}} tasks.",
"deleteFailCount_one": "{{count}} task could not be deleted.",
"deleteFailCount_other": "{{count}} tasks could not be deleted.",
"title": "Archived Tasks",
"subtitle": "View and manage tasks that have been archived or completed.",
"heading": "Archived Tasks",
"searchPlaceholder": "Search archived tasks",
"switchToCompact": "Switch to Compact View",
"switchToCard": "Switch to Card View",
"exitMultiSelect": "Exit Multi-select Mode (Ctrl+S)",
"enableMultiSelect": "Enable Multi-select Mode (Ctrl+S)",
"selected_one": "{{count}} task selected",
"selected_other": "{{count}} tasks selected",
"selectAllTitle": "Select all visible tasks (Ctrl+A)",
"all": "All",
"closeMultiSelect": "Close multi-select (Esc)",
"clearMultiSelect": "Clear multi-select (Esc)",
"close": "Close",
"clear": "Clear",
"restoreSelectedTitle": "Restore selected tasks (R)",
"deleteSelectedTitle": "Delete selected tasks (E)",
"clearSearch": "Clear search",
"clearFilters": "Clear filters",
"count_one": "{{count}} archived task",
"count_other": "{{count}} archived tasks",
"matching": " matching \"{{term}}\""
},
"edit": {
"deleteConfirm": "Are you sure you want to delete this chore?"
},
"list": {
"complete": "Complete",
"filterSaved": "Filter Saved",
"filterSavedMsg": "\"{{name}}\" has been saved",
"nothingScheduled": "Nothing scheduled",
"createChore": "Create new chore (Cmd+C)",
"filterUpdated": "Filter Updated",
"filterUpdatedMsg": "\"{{name}}\" has been updated successfully",
"advancedFilterCreated": "Advanced Filter Created",
"advancedFilterCreatedMsg": "\"{{name}}\" has been created successfully"
},
"multiToolbar": {
"skip": "Skip",
"completeTitle": "Complete selected tasks (Enter)",
"complete": "Complete",
"skipTitle": "Skip selected tasks (/)",
"archiveTitle": "Archive selected tasks (X)",
"archive": "Archive",
"deleteTitle": "Delete selected tasks (Shift+X)"
},
"remind": {
"title": "Reminders"
},
"assignee": {
"anyone": "Anyone"
},
"filterChip": {
"cancelAll": "Cancel All Filters",
"unpin": "Unpin filter",
"pin": "Pin filter",
"edit": "Edit filter",
"delete": "Delete filter"
},
"multiSelect": {
"showShortcuts": "Show keyboard shortcuts",
"title": "Multi-select Mode",
"selection": "Selection",
"selectAll": "Select all visible tasks",
"clearOrExit": "Clear selection or exit multi-select mode",
"actions": "Actions",
"markCompleted": "Mark selected tasks as completed",
"deleteSelected": "Delete selected tasks",
"interface": "Interface",
"quickAdd": "Quick add new task"
},
"notifications": {
"needTitle": "Need Notification?",
"needBody": "You need to enable permission to receive notifications, do you want to enable it?",
"keepDisabled": "No, Keep it Disabled"
},
"assigneeCard": {
"loading": "Loading tasks by assignee...",
"title": "Tasks by Assignee",
"scheduled": "Scheduled",
"pendingReview": "Pending Review"
},
"filter": {
"status": {
"inProgress": "In Progress"
}
},
"impersonate": {
"viewAs": "View tasks as",
"switchTitle": "Switch to user view",
"switchHint": "Tasks will be filtered to show only assignments for selected user",
"chooseUser": "Choose User",
"changeUser": "Change User"
},
"modals": {
"changeDueDate": "Change due date",
"completePast": "Save Chore that you completed in the past",
"delegate": "Delegate to someone else",
"selectPerformer": "Select a performer",
"addNote": "Add note to attach to this completion:",
"complete": "Complete"
},
"shortcuts": {
"allSelectedTitle": "✅ All Tasks Selected",
"someSelectedTitle": "🎯 Tasks Selected",
"allSelectedAltTitle": "🎯 All Tasks Selected"
},
"overview": {
"title": "Chores Overviews",
"search": "Search",
"newChore": "New Chore",
"unassigned": "Unassigned",
"changeDueDate": "Change due date"
},
"dataError": {
"attachmentsFailed": "Failed to fetch attachments"
},
"nudge": {
"title": "Send Nudge",
"customMessage": "Custom Message (optional)",
"messagePlaceholder": "Add a personal message with your nudge...",
"notifyAll": "Notify All Assignees",
"notifyAllHint": "If enabled, all members who can see this task will be notified. Otherwise, only the assigned person will receive the nudge."
},
"nfc": {
"errWrite": "Error writing to NFC tag. Please try again.",
"errUnsupported": "NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.",
"titleSuccess": "Tag written!",
"titleError": "Something went wrong",
"titleWaiting": "Hold near NFC tag",
"subSuccess": "Your NFC tag is ready to use.",
"subWaiting": "Keep your device near the tag until complete.",
"subIdle": "Encode this task link onto any NFC tag.",
"tagUrl": "Tag URL",
"copyUrl": "Copy URL",
"autoComplete": "Auto-complete on scan",
"autoCompleteHint": "Mark task done when tag is tapped"
},
"duePicker": {
"today": "Today",
"tomorrow": "Tomorrow",
"weekend": "Weekend",
"nextWeek": "Next week"
},
"photoTask": {
"title": "Scan photo to create task",
"cameraUnavailable": "Camera not available",
"uploadPhoto": "Upload Photo",
"scanDocument": "Scan Document",
"capture": "Capture",
"processNatively": "Process Natively",
"processImage": "Process Image",
"createTask": "Create Task",
"failedScan": "Failed scan"
},
"descriptionPlaceholder": "Enter description...",
"notifTemplate": {
"onDueDate": "On due date",
"beforeDue": "{{count}} {{unit}} before due",
"afterDue": "{{count}} {{unit}} after due",
"errDuplicate": "This notification setting already exists. Please use a different timing.",
"errOneDue": "Only one \"Due Alert\" notification is allowed.",
"errAllConfigured": "All common {{type}} times are already configured.",
"reminder": "Reminder",
"dueAlert": "Due Alert",
"followUp": "Follow-up",
"rememberFuture": "Remember for Future Tasks"
}
}

View File

@@ -56,8 +56,17 @@
"quickAction": "Quick action",
"navigation": "Navigation",
"createTask": "Create a task",
"createLabel": "Create a label",
"createProject": "Create a project",
"createFilter": "Create a filter",
"viewAllTasks": "View all tasks",
"viewArchivedTasks": "View archived tasks",
"viewThings": "View things",
"viewLabels": "View labels",
"viewProjects": "View projects",
"viewFilters": "View filters",
"viewActivities": "View activities",
"viewPoints": "View points",
"openSettings": "Open settings",
"filterTasks": "Show tasks matching “{{query}}”",
"filterTasksSubtitle": "Filter the task list"
@@ -131,7 +140,14 @@
"tooLargeTitle": "File Too Large",
"tooLargeMessage": "The file you are trying to upload is too large.",
"deniedTitle": "Permission Denied",
"deniedMessage": "You do not have permission to upload files."
"deniedMessage": "You do not have permission to upload files.",
"plusFeatureTitle": "Plus Feature",
"plusFeatureMessage": "Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.",
"upgradeTitle": "Upgrade Required",
"upgradeMessage": "Image uploads are only available for Plus accounts.",
"failedTitle": "Upload Failed",
"failedMessage": "Failed to upload image.",
"processingMessage": "An error occurred while processing the image."
},
"getStarted": "Get Started!",
"imageLoadFailed": "Failed to load image.",
@@ -154,5 +170,44 @@
"showDetails": "Show error details",
"copyToClipboard": "Copy to clipboard",
"copied": "Error details copied to clipboard"
}
},
"userMenu": {
"actAsAnother": "Act as another user",
"stopImpersonating": "Stop Impersonating",
"returnToAccount": "Return to your account",
"accountPrefs": "Account & preferences",
"invitePeople": "Invite People",
"addMembers": "Add members to your circle",
"sidePanelSettings": "Side Panel Settings"
},
"realtime": {
"newTaskTitle": "New Task Created",
"connected": "You are now receiving real-time as they happen.",
"errorTitle": "Real-time Error",
"parseTitle": "Message Error",
"parseMessage": "Failed to parse server message",
"disabledTitle": "Connection Temporarily Disabled",
"failedTitle": "Connection Failed",
"stateConnectionError": "Connection error occurred",
"stateAuthExpired": "Authentication expired - refreshing token...",
"stateTokenRefreshed": "Token refreshed - reconnecting...",
"stateRefreshInProgress": "Token refresh in progress - reconnecting soon...",
"stateSessionExpired": "Session expired - please log in again",
"stateAuthFailed": "Authentication failed - please log in again",
"stateAuthError": "Authentication error - please log in again",
"stateTimeout": "Connection timeout - reconnecting...",
"connectErrorTitle": "Connection Error",
"connectErrorMessage": "Failed to establish real-time connection. Please try again."
},
"removeAll": "Remove all",
"apply": "Apply",
"cancelAll": "Cancel all",
"sync": {
"aria": "Open sync and network status",
"allSynced": "All changes synced",
"willSync": "Will sync when back online",
"cancelAll": "Cancel All"
},
"attachments": "Attachments",
"fileNumbered": "File {{index}}"
}

View File

@@ -13,5 +13,11 @@
"nameExists": "A filter with this name already exists",
"tasks_one": "{{count}} task",
"tasks_other": "{{count}} tasks",
"overdue": "{{count}} overdue"
"overdue": "{{count}} overdue",
"delete": {
"title": "Delete Filter"
},
"empty": {
"title": "No saved filters yet"
}
}

View File

@@ -76,7 +76,9 @@
"hasNotes": "Has Notes",
"hasPoints": "Has Points",
"timePeriod": "Time Period",
"user": "User"
"user": "User",
"completedBy": "Completed By",
"completedAt": "Completed At"
},
"period": {
"days_one": "{{count}} Day",
@@ -85,5 +87,26 @@
},
"activities": {
"title": "Activities"
},
"title": {
"summary": "Task Summary",
"activity": "Task Activity"
},
"noResults": {
"clear": "Clear filters"
},
"toast": {
"updateQueued": {
"title": "History Update Queued"
},
"updated": {
"title": "History Updated"
},
"deleteQueued": {
"title": "History Delete Queued"
},
"deleted": {
"title": "History Deleted"
}
}
}

View File

@@ -5,5 +5,43 @@
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
},
"loadError": "Failed to load labels. Please try again.",
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task."
"search": {
"placeholder": "Search labels",
"noResultsTitle": "No labels match",
"noResultsDescription": "No label matches \"{{searchTerm}}\".",
"noFilterResultsDescription": "No label matches the current filter.",
"clear": "Clear search",
"showAll": "Show all labels"
},
"detail": {
"taskCount_one": "{{count}} task",
"taskCount_other": "{{count}} tasks",
"labelActions": "Label actions",
"filters": {
"all": "All",
"overdue": "Overdue",
"today": "Today",
"undated": "No date"
},
"noMatchingStatus": "No task in this label is in that state right now.",
"clearFilters": "Show all tasks",
"searchPlaceholder": "Search tasks in this label",
"emptyTitle": "No tasks with this label",
"emptyDescription": "Nothing is tagged \"{{label}}\" yet. Add the label to a task and it will show up here.",
"browseTasks": "Browse tasks",
"noResultsTitle": "No tasks match",
"noResultsDescription": "No task in this label matches \"{{searchTerm}}\".",
"notFoundTitle": "Label not found",
"notFoundDescription": "This label may have been deleted or is no longer shared with you.",
"backToLabels": "Back to labels"
},
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task.",
"modal": {
"errorEmptyName": "Name cannot be empty",
"errorDuplicate": "Label with this name already exists",
"errorNoColor": "Please select a color",
"saveFailedTitle": "Failed to save label",
"saveFailedMessage": "Unable to save label. Please try again.",
"name": "Name"
}
}

View File

@@ -12,6 +12,14 @@
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
},
"loadError": "Failed to load projects. Please try again.",
"search": {
"placeholder": "Search projects",
"noResultsTitle": "No projects match",
"noResultsDescription": "No project matches \"{{searchTerm}}\".",
"noFilterResultsDescription": "No project matches the current filter.",
"clear": "Clear search",
"showAll": "Show all projects"
},
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
"defaultDescription": "All tasks without a specific project",
"selector": {
@@ -20,5 +28,12 @@
"createNewDescription": "Add a custom project workspace",
"manage": "Manage Projects",
"manageDescription": "View, edit, and organize all projects"
},
"modal": {
"errorNameRequired": "Project name is required",
"errorUpdate": "Failed to update project",
"errorCreate": "Failed to create project",
"namePlaceholder": "Enter project name...",
"descriptionPlaceholder": "Optional project description..."
}
}

View File

@@ -352,7 +352,8 @@
"backupCodesModal": {
"title": "New Backup Codes",
"warning": "Your previous backup codes are now invalid. Save these new codes in a safe place. Each code can only be used once."
}
},
"generateCodes": "Generate New Codes"
},
"apiTokens": {
"title": "API Tokens",
@@ -576,5 +577,8 @@
"selectOwner": "Select new owner",
"confirmPrompt": "Please enter your password and type DELETE to confirm",
"typeDelete": "DELETE"
},
"realtime": {
"titleSse": "Real-time Updates (SSE)"
}
}

View File

@@ -9,7 +9,16 @@
"sessionDeletedMessage": "Timer session has been deleted successfully.",
"sessionDeleteErrorTitle": "Error deleting session",
"deleteConfirmTitle": "Delete Timer Session",
"deleteConfirmMessage": "Are you sure you want to delete this timer session?"
"deleteConfirmMessage": "Are you sure you want to delete this timer session?",
"startedTitle": "Timer Started",
"startedMessage": "Work session has been started successfully.",
"startQueuedTitle": "Start queued",
"startFailTitle": "Failed to start timer",
"pausedTitle": "Timer Paused",
"pausedMessage": "Work session has been paused.",
"pauseQueuedTitle": "Pause queued",
"pauseFailTitle": "Failed to pause timer",
"deleteSessionTitle": "Delete Session"
},
"loading": "Loading timer data...",
"noData": "No timer data found for this chore.",
@@ -25,5 +34,6 @@
"startTime": "Start Time",
"endTime": "End Time",
"leaveEmpty": "Leave empty if session is ongoing",
"noSessionForChore": "No timer session found for this chore."
"noSessionForChore": "No timer session found for this chore.",
"saveChanges": "Save Changes"
}

View File

@@ -492,4 +492,3 @@
}
}
}

View File

@@ -72,4 +72,4 @@
"delete": "删除会话"
}
}
}
}

View File

@@ -13,26 +13,30 @@ import Option from '@mui/joy/Option'
import Select from '@mui/joy/Select'
import Typography from '@mui/joy/Typography'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils'
const timeUnits = TIME_UNITS
const timingOptions = [
{ label: 'Before', value: 'before' },
{ label: 'Due', value: 'ondue' },
{ label: 'After', value: 'after' },
{ value: 'before' },
{ value: 'ondue' },
{ value: 'after' },
]
function getRelativeLabel(notification) {
const { value, unit } = notification
function getRelativeLabel(notification, t) {
const { unit, value } = notification
const numericValue = Number(value)
if (numericValue === 0) {
return 'On due date'
return t('notifTemplate.onDueDate')
}
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
const unitName = t(`notifTemplate.unitName.${unit}`)
const absValue = Math.abs(numericValue)
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
return numericValue < 0
? t('notifTemplate.beforeDue', { count: absValue, unit: unitName })
: t('notifTemplate.afterDue', { count: absValue, unit: unitName })
}
// Helper functions to convert between internal value and UI representation
@@ -68,9 +72,10 @@ const NotificationTemplate = ({
// Consumers that own an empty state themselves pass 0.
minNotifications = 1,
onChange,
value,
showTimeline = true,
value,
}) => {
const { t } = useTranslation('chores')
const [notifications, setNotifications] = useState(
value?.templates ||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
@@ -218,9 +223,7 @@ const NotificationTemplate = ({
if (!currentNotification) return
if (isDuplicate(currentNotification, idx, currentList)) {
setError(
'This notification setting already exists. Please use a different timing.',
)
setError(t('notifTemplate.errDuplicate'))
return
}
}
@@ -232,14 +235,14 @@ const NotificationTemplate = ({
if (type === 'due') {
if (notificationsRef.current.some(n => Number(n.value) === 0)) {
setError('Only one "Due Alert" notification is allowed.')
setError(t('notifTemplate.errOneDue'))
return
}
newNotification = { value: 0, unit: 'm' }
} else {
newNotification = getSmartSuggestion(type)
if (!newNotification) {
setError(`All common ${type} times are already configured.`)
setError(t('notifTemplate.errAllConfigured', { type }))
return
}
}
@@ -364,7 +367,7 @@ const NotificationTemplate = ({
fontSize: '0.6rem',
}}
>
Due Date
{t('group.dueDate')}
</Typography>
</Box>
@@ -398,7 +401,7 @@ const NotificationTemplate = ({
zIndex: 10,
},
}}
title={getRelativeLabel(n)}
title={getRelativeLabel(n, t)}
>
<Badge
badgeContent={
@@ -550,7 +553,7 @@ const NotificationTemplate = ({
fontSize: 14,
}}
>
{getRelativeLabel(n)}
{getRelativeLabel(n, t)}
</Typography>
</Box>
@@ -575,7 +578,7 @@ const NotificationTemplate = ({
value={opt.value}
disabled={opt.value === 'ondue' && hasOnDueElsewhere}
>
{opt.label}
{t(`notifTemplate.timing.${opt.value}`)}
</Option>
))}
</Select>
@@ -638,7 +641,7 @@ const NotificationTemplate = ({
>
{timeUnits.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
{t(`notifTemplate.unitShort.${opt.value}`)}
</Option>
))}
</Select>
@@ -688,7 +691,7 @@ const NotificationTemplate = ({
},
}}
>
Reminder
{t('notifTemplate.reminder')}
</Button>
<Button
onClick={() => addSmartNotification('due')}
@@ -710,7 +713,7 @@ const NotificationTemplate = ({
},
}}
>
Due Alert
{t('notifTemplate.dueAlert')}
</Button>
<Button
onClick={() => addSmartNotification('followup')}
@@ -729,7 +732,7 @@ const NotificationTemplate = ({
},
}}
>
Follow-up
{t('notifTemplate.followUp')}
</Button>
</Box>
{showSaveDefault && (
@@ -762,7 +765,7 @@ const NotificationTemplate = ({
setShowSaveDefault(false)
}}
>
Remember for Future Tasks
{t('notifTemplate.rememberFuture')}
</Button>
</Box>
)}

View File

@@ -1,13 +1,14 @@
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext'
const SSEConnectionStatus = ({
variant = 'minimal',
showError = false,
sx = {},
variant = 'minimal',
}) => {
const { isConnected, isConnecting, error, getConnectionStatus } =
const { error, getConnectionStatus, isConnected, isConnecting } =
useSSEContext()
const getStatusColor = () => {

View File

@@ -9,20 +9,23 @@ import {
Switch,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus'
const SSESettings = () => {
const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile()
const {
isConnected,
isConnecting,
error,
getConnectionStatus,
toggleSSEEnabled,
isConnected,
isConnecting,
isSSEEnabled,
toggleSSEEnabled,
} = useSSEContext()
const handleToggle = () => {
@@ -75,7 +78,7 @@ const SSESettings = () => {
)}
<Box sx={{ flex: 1 }}>
<Typography level='title-md'>
Real-time Updates (SSE)
{t('realtime.titleSse')}
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature

View File

@@ -1,13 +1,14 @@
import { Check, Star } from '@mui/icons-material'
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
import { useState } from 'react'
import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
import { useTranslation } from 'react-i18next'
const SubscriptionModal = ({ open, onClose }) => {
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
const SubscriptionModal = ({ onClose, open }) => {
const { t } = useTranslation('settings')
const [selectedPlan, setSelectedPlan] = useState('yearly')
const [isLoading, setIsLoading] = useState(false)
@@ -81,7 +82,11 @@ const SubscriptionModal = ({ open, onClose }) => {
footer={
<ModalActions
stackOnMobile
secondary={{ label: t('accountSettings.cancel'), onClick: onClose, disabled: isLoading }}
secondary={{
label: t('accountSettings.cancel'),
onClick: onClose,
disabled: isLoading,
}}
primary={{
label: t('subscription.subscribe'),
onClick: handleSubscribe,

View File

@@ -27,7 +27,9 @@ import {
import { useMediaQuery } from '@mui/material'
import moment from 'moment'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
import useStickyState from '../hooks/useStickyState'
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
@@ -37,15 +39,16 @@ import UserModal from '../views/Modals/Inputs/UserModal'
import SubscriptionModal from './SubscriptionModal'
const UserProfileAvatar = () => {
const { t } = useTranslation('common')
const navigate = useNavigate()
const { mode, setMode } = useColorScheme()
const { data: userProfile } = useUserProfile()
const {
canImpersonate,
getEffectiveUser,
isImpersonating,
startImpersonation,
stopImpersonation,
canImpersonate,
getEffectiveUser,
} = useImpersonateUser()
const { data: circleMembersData } = useCircleMembers()
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -127,7 +130,9 @@ const UserProfileAvatar = () => {
}}
/>
<Avatar
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
src={resolvePhotoURL(
userProfile?.image || userProfile?.avatar,
)}
alt={userProfile?.displayName || userProfile?.name}
size='sm'
sx={{
@@ -297,7 +302,7 @@ const UserProfileAvatar = () => {
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Act as another user
{t('userMenu.actAsAnother')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -319,13 +324,13 @@ const UserProfileAvatar = () => {
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Stop Impersonating
{t('userMenu.stopImpersonating')}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Return to your account
{t('userMenu.returnToAccount')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -349,13 +354,13 @@ const UserProfileAvatar = () => {
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Settings
{t('settings')}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Account & preferences
{t('userMenu.accountPrefs')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -374,13 +379,13 @@ const UserProfileAvatar = () => {
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Invite People
{t('userMenu.invitePeople')}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Add members to your circle
{t('userMenu.addMembers')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -401,7 +406,7 @@ const UserProfileAvatar = () => {
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Side Panel Settings
{t('userMenu.sidePanelSettings')}
</Typography>
<Typography
level='body-xs'

View File

@@ -1,40 +1,43 @@
import React from 'react'
import { Box } from '@mui/joy'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
import './PageTransition.css'
const AnimatedList = ({
children,
staggerDelay = 50,
animationType = 'stagger', // 'stagger', 'fade', 'slide'
direction = 'up', // 'up', 'down', 'left', 'right'
renderItem,
import { Box } from '@mui/joy'
import React from 'react'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import {
useReducedMotion,
useStaggeredAnimation,
} from '../../hooks/useAnimations'
const AnimatedList = ({
animationType = 'stagger',
children,
direction = 'up', // 'stagger', 'fade', 'slide'
items, // 'up', 'down', 'left', 'right'
keyExtractor,
items,
...boxProps
renderItem,
staggerDelay = 50,
...boxProps
}) => {
// Handle both children and items patterns
let childrenArray
if (items && renderItem) {
childrenArray = items.map((item, index) =>
React.cloneElement(renderItem(item, index), {
key: keyExtractor ? keyExtractor(item, index) : index
})
childrenArray = items.map((item, index) =>
React.cloneElement(renderItem(item, index), {
key: keyExtractor ? keyExtractor(item, index) : index,
}),
)
} else {
childrenArray = React.Children.toArray(children)
}
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
const prefersReducedMotion = useReducedMotion()
// If user prefers reduced motion, render without animations
if (prefersReducedMotion) {
return (
<Box {...boxProps}>
{items && renderItem ? childrenArray : children}
</Box>
<Box {...boxProps}>{items && renderItem ? childrenArray : children}</Box>
)
}
@@ -55,7 +58,7 @@ const AnimatedList = ({
<TransitionGroup component={null}>
{childrenArray.map((child, index) => {
const isVisible = visibleItems.has(index)
return (
<CSSTransition
key={child.key || index}

View File

@@ -65,11 +65,7 @@ const LogoContainer = styled(Box)({
import { useTranslation } from 'react-i18next'
const LoadingScreen = ({
message = null,
showLogo = true,
size = 'lg',
}) => {
const LoadingScreen = ({ message = null, showLogo = true, size = 'lg' }) => {
const { t } = useTranslation('common')
return (
<LoadingContainer>
@@ -90,7 +86,7 @@ const LoadingScreen = ({
</Typography>
</LogoContainer>
)}
<CircularProgress
size={size}
sx={{
@@ -98,7 +94,7 @@ const LoadingScreen = ({
mb: 2,
}}
/>
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
</LoadingContent>
</LoadingContainer>

View File

@@ -1,7 +1,8 @@
import './PageTransition.css'
import { useLayoutEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useLocation } from 'react-router-dom'
import './PageTransition.css'
// Route hierarchy for determining navigation direction
const routeHierarchy = {

View File

@@ -1,11 +1,11 @@
import { Box, Skeleton } from '@mui/joy'
const SkeletonLoader = ({
type = 'card',
count = 1,
height = 100,
width = '100%',
type = 'card',
variant = 'rectangular',
width = '100%',
...props
}) => {
const renderSkeleton = () => {

View File

@@ -1,23 +1,23 @@
import React from 'react'
import { Card } from '@mui/joy'
import { styled } from '@mui/joy/styles'
import React from 'react'
const AnimatedCard = styled(Card)(({ theme }) => ({
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'translateZ(0)', // Enable GPU acceleration
cursor: 'pointer',
position: 'relative',
'&:hover': {
transform: 'translateY(-4px) translateZ(0)',
boxShadow: theme.shadow.lg,
},
'&:active': {
transform: 'translateY(-2px) translateZ(0)',
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
},
// Subtle background animation on hover
'&::before': {
content: '""',
@@ -26,49 +26,50 @@ const AnimatedCard = styled(Card)(({ theme }) => ({
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
background:
'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
opacity: 0,
transition: 'opacity 0.3s ease',
pointerEvents: 'none',
borderRadius: 'inherit',
},
'&:hover::before': {
opacity: 1,
},
// Focus states for accessibility
'&:focus-visible': {
outline: '2px solid',
outlineColor: theme.palette.primary[500],
outlineOffset: '2px',
},
// Reduced motion support
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none !important',
'&:hover': {
transform: 'none',
boxShadow: theme.shadow.md, // Still provide visual feedback
},
'&:active': {
transform: 'none',
},
'&::before': {
display: 'none',
},
},
}))
const SmoothCard = ({
children,
onClick,
const SmoothCard = ({
animationDisabled = false,
...props
children,
onClick,
...props
}) => {
if (animationDisabled) {
return (

View File

@@ -1,13 +1,14 @@
import './PageTransition.css'
import { Box } from '@mui/joy'
import React, { useEffect, useState } from 'react'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import './PageTransition.css'
const StaggeredList = ({
children,
staggerDelay = 50,
initialDelay = 0,
animate = true,
children,
initialDelay = 0,
staggerDelay = 50,
}) => {
const [isVisible, setIsVisible] = useState(!animate)

View File

@@ -17,27 +17,27 @@ const WIDTH_BY_SIZE = {
const AppModal = forwardRef(
(
{
open,
onClose,
backdropBlur = true,
children,
title,
closeOnBackdrop = true,
closeOnEscape = true,
contentSx,
description,
footer,
size = 'md',
footerSx,
fullWidth = true,
isMobile: isMobileProp,
keepMounted = false,
maxHeight = '90dvh',
mobilePresentation = 'sheet',
onClose,
open,
role = 'dialog',
showCloseButton = true,
showHandle = false,
closeOnBackdrop = true,
closeOnEscape = true,
backdropBlur = true,
maxHeight = '90dvh',
contentSx,
footerSx,
size = 'md',
sx,
title,
unmountDelay = 180,
...modalProps
},
@@ -183,6 +183,7 @@ const AppModal = forwardRef(
onClick={handleClose}
sx={{
position: 'absolute',
zIndex: 1,
top: isSheet && showHandle ? 6 : 12,
right: { xs: 10, sm: 16 },
borderRadius: '50%',

View File

@@ -1,6 +1,7 @@
import { Add, Remove } from '@mui/icons-material'
import { Box, IconButton, Input, Option, Select } from '@mui/joy'
import { useEffect, useState } from 'react'
import {
secondsToValueAndUnit,
TIME_UNITS,
@@ -16,7 +17,7 @@ import {
* size Joy UI size ('sm' | 'md')
* minValue minimum numeric value (default 1)
*/
const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
const DurationInput = ({ minValue = 1, onChange, size = 'md', value }) => {
const derived =
value != null && value >= 0
? secondsToValueAndUnit(value)
@@ -26,7 +27,7 @@ const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
useEffect(() => {
if (value != null && value >= 0) {
const { value: v, unit: u } = secondsToValueAndUnit(value)
const { unit: u, value: v } = secondsToValueAndUnit(value)
setDisplayValue(v)
setUnit(u)
}

View File

@@ -55,7 +55,7 @@ const SIZES = {
}
const ActionButton = ({ action, ...buttonProps }) => {
const { label, to, onClick, ...rest } = action
const { label, onClick, to, ...rest } = action
return (
<Button
{...buttonProps}
@@ -73,15 +73,15 @@ ActionButton.propTypes = {
}
const EmptyState = ({
variant = 'empty',
icon,
title,
description,
fullHeight = false,
icon,
primaryAction,
secondaryAction,
size = 'md',
fullHeight = false,
sx,
title,
variant = 'empty',
...rest
}) => {
const tone = TONES[variant] || TONES.empty

View File

@@ -10,9 +10,10 @@ import {
Typography,
} from '@mui/joy'
import { useState } from 'react'
import AppModal from './AppModal'
import ModalActions from './ModalActions'
import ActiveFilterChips from './filter/ActiveFilterChips'
import ModalActions from './ModalActions'
/**
* Reusable filter bar component.
@@ -141,14 +142,25 @@ const fmtDisplayDate = iso => {
// ── Component ────────────────────────────────────────────────────────────────
const FilterBar = ({
filterDefs,
activeFilters,
onSetFilter,
filterDefs,
onClearAll,
onOpenChange,
onSetFilter,
open,
// When the host renders its own trigger (e.g. an icon button in a toolbar
// row), it drives the sheet through `open`/`onOpenChange` and hides ours.
resultCount,
showTrigger = true,
totalCount,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [internalOpen, setInternalOpen] = useState(false)
const isControlled = open !== undefined
const isOpen = isControlled ? open : internalOpen
const setIsOpen = next => {
if (!isControlled) setInternalOpen(next)
onOpenChange?.(next)
}
// ── Active count ───────────────────────────────────────────────────────────
@@ -293,65 +305,75 @@ const FilterBar = ({
// ── Render ─────────────────────────────────────────────────────────────────
const activeChips = filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))
// With the trigger hoisted into a toolbar, the inline row has nothing to show
// until a filter is on — rendering it anyway would leave a phantom gap.
const showInlineBar = showTrigger || activeChips.length > 0
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mb: 2,
}}
>
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
{showInlineBar && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mb: 2,
}}
>
<Button
size='md'
variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)}
sx={{
borderRadius: 'xl',
py: 0.5,
px: 1,
gap: 0.5,
alignItems: 'center',
'& .MuiButton-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
{showTrigger && (
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<Button
size='md'
variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)}
sx={{
borderRadius: 'xl',
py: 0.5,
px: 1,
gap: 0.5,
alignItems: 'center',
'& .MuiButton-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
)}
<ActiveFilterChips
chips={filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))}
onOpen={() => setIsOpen(true)}
onClearAll={hasActive ? onClearAll : undefined}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
<ActiveFilterChips
chips={activeChips}
onOpen={() => setIsOpen(true)}
onClearAll={hasActive ? onClearAll : undefined}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
)}
{/* ── Bottom sheet ────────────────────────────────────── */}
<AppModal

View File

@@ -9,10 +9,10 @@ import PropTypes from 'prop-types'
function KeyboardShortcutHint({
shortcut,
show = true,
withCmd = true,
withCtrl, // Legacy prop for backward compatibility
withShift = false,
sx = {},
withCmd = true, // Legacy prop for backward compatibility
withCtrl,
withShift = false,
...props
}) {
if (!show) return null

View File

@@ -16,12 +16,12 @@ const ActionButton = ({ action, defaults, sx }) => {
* primary action is always the final, highest-emphasis control.
*/
const ModalActions = ({
children,
primary,
secondary,
tertiary,
children,
stackOnMobile = false,
sx,
tertiary,
}) => {
const responsiveButtonStyles = stackOnMobile
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }

View File

@@ -0,0 +1,256 @@
import { ArrowDownward, ArrowUpward, Check, Sort } from '@mui/icons-material'
import {
Box,
Divider,
IconButton,
ListItemContent,
ListItemDecorator,
Menu,
MenuItem,
Radio,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
/**
* Compact sort + filter menu, meant to sit next to a search input.
*
* Props:
* sortOptions - [{ name, value }] shown under the sort header
* selectedSort - currently selected sort value
* onSortChange - (value) => void
* sortDirection - 'asc' | 'desc'
* onSortDirectionChange - (direction) => void
* filterTitle - optional header for the filter section
* filterOptions - optional [{ name, value }] rendered as radios
* selectedFilter - currently selected filter value
* onFilterChange - (value) => void
* isActive - highlights the trigger button when a non-default choice is on
*/
const SortAndFilterMenu = ({
filterOptions,
filterTitle,
icon = <Sort />,
isActive,
onFilterChange,
onSortChange,
onSortDirectionChange,
selectedFilter,
selectedSort,
sortDirection = 'asc',
sortOptions = [],
title = 'Sort by',
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null)
const buttonRef = useRef(null)
const handleMenuClose = () => setAnchorEl(null)
useEffect(() => {
const handleMenuOutsideClick = event => {
if (
menuRef.current &&
!menuRef.current.contains(event.target) &&
!buttonRef.current?.contains(event.target)
) {
handleMenuClose()
}
}
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [])
const SectionHeader = ({ children }) => (
<MenuItem
disabled
sx={{
borderRadius: 'var(--joy-radius-sm)',
cursor: 'default',
opacity: 1,
}}
>
<ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{children}
</Typography>
</ListItemContent>
</MenuItem>
)
return (
<>
<IconButton
ref={buttonRef}
onClick={event => setAnchorEl(anchorEl ? null : event.currentTarget)}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%', flexShrink: 0 }}
aria-label='Sort and filter options'
title='Sort & Filter'
>
{icon}
</IconButton>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
placement='bottom-end'
sx={{
minWidth: 240,
p: 1,
'--List-gap': '4px',
boxShadow: 'var(--joy-shadow-lg)',
border: '1px solid var(--joy-palette-divider)',
borderRadius: 'var(--joy-radius-md)',
zIndex: 1300,
}}
>
<SectionHeader>{title}</SectionHeader>
<Divider sx={{ my: 1 }} />
{sortOptions.map(option => (
<MenuItem
key={option.value}
onClick={() => {
onSortChange(option.value)
handleMenuClose()
}}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedSort === option.value
? 'var(--joy-palette-primary-softBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedSort === option.value
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: selectedSort === option.value ? 600 : 400,
color:
selectedSort === option.value
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
{option.name}
</Typography>
{selectedSort === option.value && (
<Check
sx={{
fontSize: '16px',
color: 'var(--joy-palette-primary-500)',
}}
/>
)}
</Box>
</ListItemContent>
</MenuItem>
))}
{onSortDirectionChange && (
<>
<Divider sx={{ my: 1 }} />
<MenuItem
onClick={() =>
onSortDirectionChange(sortDirection === 'asc' ? 'desc' : 'asc')
}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
{sortDirection === 'asc' ? (
<ArrowUpward sx={{ fontSize: '18px' }} />
) : (
<ArrowDownward sx={{ fontSize: '18px' }} />
)}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>
{sortDirection === 'asc' ? 'Ascending' : 'Descending'}
</Typography>
</ListItemContent>
</MenuItem>
</>
)}
{filterOptions?.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<SectionHeader>{filterTitle || 'Filter'}</SectionHeader>
{filterOptions.map(option => (
<MenuItem
key={option.value}
onClick={() => {
onFilterChange(option.value)
handleMenuClose()
}}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === option.value
? 'var(--joy-palette-primary-softBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === option.value
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
<Radio
checked={selectedFilter === option.value}
variant='outlined'
/>
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{
fontWeight: selectedFilter === option.value ? 600 : 400,
color:
selectedFilter === option.value
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
{option.name}
</Typography>
</ListItemContent>
</MenuItem>
))}
</>
)}
</Menu>
</>
)
}
export default SortAndFilterMenu

View File

@@ -1,6 +1,5 @@
import { Add, Close } from '@mui/icons-material'
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
const ActiveFilterChips = ({

View File

@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { initWidgetSync } from '../service/WidgetService'
const QueryContext = ({ children }) => {

View File

@@ -27,6 +27,7 @@ import JoinCircleView from '../views/Circles/JoinCircle'
import NotFound from '../views/components/NotFound'
import FilterView from '../views/Filters/FilterView'
import ChoreHistory from '../views/History/ChoreHistory'
import LabelDetailView from '../views/Labels/LabelDetailView'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing'
import CircleSetupView from '../views/Onboarding/CircleSetupView'
@@ -262,6 +263,10 @@ const Router = createBrowserRouter([
path: 'labels/',
element: <LabelView />,
},
{
path: 'labels/:labelId',
element: <LabelDetailView />,
},
{
path: 'projects/',
element: <ProjectView />,

View File

@@ -1,4 +1,5 @@
import { createContext, useContext } from 'react'
import { useSSE } from '../hooks/useSSE'
export const SSEContext = createContext({

View File

@@ -31,6 +31,32 @@ const primaryPalette = getPalette(primaryColor)
const CONTROL_RADIUS = '12px'
const ICON_BUTTON_RADIUS = '10px'
// Select and Autocomplete both render their popup through @mui/base's Popper,
// which defaults to popper.js's `absolute` strategy — the popup is laid out in
// *document* coordinates. That is fine on desktop, but inside a mobile bottom
// sheet (viewport-pinned, body scroll locked by Modal) it puts the list below
// the fold or lets an overflow:hidden ancestor clip it, so it reads as trapped
// inside the modal. `fixed` positions it in viewport space instead, which no
// ancestor can clip.
//
// Note that slotProps coming from defaultProps are not deep merged: a call site
// passing its own slotProps.listbox replaces this wholesale.
const POPUP_LISTBOX = {
popperOptions: { strategy: 'fixed' },
modifiers: [
{ name: 'flip', options: { padding: 8 } },
// altAxis with tether off lets the popup shift fully back into view rather
// than staying glued to an anchor that has no room around it.
{
name: 'preventOverflow',
options: { padding: 8, altAxis: true, tether: false },
},
],
// Joy hardcodes 44vh, and vh resolves to the *large* viewport on mobile —
// taller than what is actually on screen while the browser chrome is up.
sx: { maxHeight: 'min(44dvh, 320px)' },
}
const themeConfig = {
radius: {
xs: '6px',
@@ -163,6 +189,10 @@ const themeConfig = {
theme.direction === 'rtl' ? buttonGroupRtlGeometry(ownerState) : {},
},
},
JoySelect: { defaultProps: { slotProps: { listbox: POPUP_LISTBOX } } },
JoyAutocomplete: {
defaultProps: { slotProps: { listbox: POPUP_LISTBOX } },
},
},
}

View File

@@ -1,4 +1,5 @@
import { Network } from '@capacitor/network'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
class NetworkManager {

View File

@@ -36,4 +36,4 @@ const useAcknowledgmentModal = () => {
}
}
export default useAcknowledgmentModal
export default useAcknowledgmentModal

View File

@@ -8,7 +8,7 @@ export const useReducedMotion = () => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setPrefersReducedMotion(mediaQuery.matches)
const handleChange = (event) => {
const handleChange = event => {
setPrefersReducedMotion(event.matches)
}
@@ -32,13 +32,13 @@ export const useStaggeredAnimation = (itemCount, delay = 50) => {
}
const timeouts = []
// Stagger the appearance of items
for (let i = 0; i < itemCount; i++) {
const timeout = setTimeout(() => {
setVisibleItems(prev => new Set([...prev, i]))
}, i * delay)
timeouts.push(timeout)
}
@@ -62,7 +62,7 @@ export const useInViewAnimation = (threshold = 0.1) => {
([entry]) => {
setIsInView(entry.isIntersecting)
},
{ threshold }
{ threshold },
)
observer.observe(element)

View File

@@ -1,5 +1,6 @@
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'

View File

@@ -40,4 +40,4 @@ const useConfirmationModal = () => {
}
}
export default useConfirmationModal
export default useConfirmationModal

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { patchDescriptionHtml } from '../utils/ImageCache'
// Returns description HTML safe to render: embedded images with an expired

View File

@@ -10,8 +10,14 @@ import { Capacitor } from '@capacitor/core'
function normalizeScannedImage(raw) {
if (!raw) return null
if (raw.startsWith('data:')) return raw
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
if (
raw.startsWith('http://') ||
raw.startsWith('https://') ||
raw.startsWith('content://')
)
return raw
if (raw.startsWith('/') || raw.startsWith('file://'))
return Capacitor.convertFileSrc(raw)
// iOS base64 without prefix
return `data:image/jpeg;base64,${raw}`
}
@@ -25,11 +31,16 @@ function normalizeScannedImage(raw) {
export function useDocumentScanner() {
const isNativeScanner = Capacitor.isNativePlatform()
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = false } = {}) => {
const scanDocument = async ({
letUserAdjustCrop = false,
maxDocuments = 1,
quality = 90,
} = {}) => {
if (!isNativeScanner) return { image: null, cancelled: false }
try {
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
const { DocumentScanner } =
await import('@capgo/capacitor-document-scanner')
const { scannedImages } = await DocumentScanner.scanDocument({
croppedImageQuality: quality,
maxNumDocuments: maxDocuments,

View File

@@ -1,11 +1,11 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
import { useTranslation } from 'react-i18next'
export const useFileUpload = ({
draftId,

View File

@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { commandQueue } from '../utils/CommandQueue'
// Hook to get pending commands for a specific chore (for showing pending badges/undo)

View File

@@ -1,5 +1,6 @@
import useMediaQuery from '@mui/material/useMediaQuery'
import { createElement } from 'react'
import AppModal from '../components/common/AppModal'
const MobileAppModal = props =>

View File

@@ -2,6 +2,8 @@ import { Capacitor } from '@capacitor/core'
import { useQueryClient } from '@tanstack/react-query'
import { EventSourcePolyfill } from 'event-source-polyfill'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useUserProfile } from '../queries/UserQueries'
import { useAlerts } from '../service/AlertsProvider'
import { useNotification } from '../service/NotificationProvider'
@@ -19,6 +21,7 @@ const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
export const useSSE = () => {
const { t } = useTranslation('common')
const { isAuthenticated, token } = useAuth()
// Only fetch user profile if authenticated - prevents unnecessary API calls on landing page
const { data: userProfile } = useUserProfile()
@@ -97,7 +100,7 @@ export const useSSE = () => {
case 'chore.created':
showNotification({
type: 'info',
title: 'New Task Created',
title: t('realtime.newTaskTitle'),
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
duration: 5000,
})
@@ -242,14 +245,14 @@ export const useSSE = () => {
showAlert({
type: 'success',
color: 'success',
message: 'You are now receiving real-time as they happen.',
message: t('realtime.connected'),
})
break
case 'error':
console.error('SSE error event:', eventData.data)
showError({
title: 'Real-time Error',
title: t('realtime.errorTitle'),
message:
eventData.data.message ||
'An error occurred with real-time updates',
@@ -262,8 +265,8 @@ export const useSSE = () => {
} catch (err) {
console.error('Failed to parse SSE message:', err)
showError({
title: 'Message Error',
message: 'Failed to parse server message',
title: t('realtime.parseTitle'),
message: t('realtime.parseMessage'),
})
return // Stop processing if JSON parsing fails
}
@@ -315,7 +318,7 @@ export const useSSE = () => {
if (isCircuitBreakerOpen) {
console.log('SSE: Circuit breaker is open, preventing connection attempt')
showError({
title: 'Connection Temporarily Disabled',
title: t('realtime.disabledTitle'),
message:
'Connection blocked due to repeated failures. Please try again later.',
})
@@ -328,7 +331,7 @@ export const useSSE = () => {
)
setIsCircuitBreakerOpen(true)
showError({
title: 'Connection Failed',
title: t('realtime.failedTitle'),
message:
'Maximum connection attempts reached. SSE disabled for 10 minutes.',
})
@@ -378,7 +381,7 @@ export const useSSE = () => {
const ticket = await fetchSSETicket()
if (!ticket) {
console.error('SSE: Failed to obtain connection ticket')
setError('Connection error occurred')
setError(t('realtime.stateConnectionError'))
setConnectionState(SSE_STATES.CLOSED)
scheduleReconnect(
RECONNECT_INTERVALS[
@@ -511,7 +514,7 @@ export const useSSE = () => {
if (is401Error) {
console.log('SSE 401 error detected, attempting token refresh...')
setError('Authentication expired - refreshing token...')
setError(t('realtime.stateAuthExpired'))
try {
const refreshResult = await apiClient.refreshToken()
@@ -520,7 +523,7 @@ export const useSSE = () => {
console.log(
'Token refreshed successfully, retrying SSE connection...',
)
setError('Token refreshed - reconnecting...')
setError(t('realtime.stateTokenRefreshed'))
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
console.log(
@@ -553,7 +556,7 @@ export const useSSE = () => {
console.log(
'SSE: Token refresh in progress by another request, waiting...',
)
setError('Token refresh in progress - reconnecting soon...')
setError(t('realtime.stateRefreshInProgress'))
reconnectAttemptsRef.current = 0
@@ -572,23 +575,23 @@ export const useSSE = () => {
return
} else if (refreshResult.error === 'Refresh token expired') {
console.error('Refresh token expired, user must login again')
setError('Session expired - please log in again')
setError(t('realtime.stateSessionExpired'))
return
} else {
console.error('Token refresh failed:', refreshResult.error)
setError('Authentication failed - please log in again')
setError(t('realtime.stateAuthFailed'))
return
}
} catch (refreshError) {
console.error('Token refresh error:', refreshError)
setError('Authentication error - please log in again')
setError(t('realtime.stateAuthError'))
return
}
} else if (isTimeoutError) {
console.log('SSE timeout detected, attempting reconnection...')
setError('Connection timeout - reconnecting...')
setError(t('realtime.stateTimeout'))
} else {
setError('Connection error occurred')
setError(t('realtime.stateConnectionError'))
}
// Schedule reconnect for non-401 errors
@@ -618,8 +621,8 @@ export const useSSE = () => {
} catch (err) {
console.error('Failed to create SSE connection:', err)
showError({
title: 'Connection Error',
message: 'Failed to establish real-time connection. Please try again.',
title: t('realtime.connectErrorTitle'),
message: t('realtime.connectErrorMessage'),
})
setConnectionState(SSE_STATES.CLOSED)
}

View File

@@ -1,4 +1,5 @@
import { useContext } from 'react'
import { SSEContext } from '../contexts/SSEContext'
export const useSSEContext = () => {

View File

@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next'
function MyComponent() {
const { t } = useTranslation('settings') // or 'common', 'chores'
return <h1>{t('title')}</h1>
}
```
@@ -40,9 +40,9 @@ import { useLocalization } from '@/contexts/LocalizationContext'
function MyComponent() {
const { formatDate, formatDateTime, formatRelative } = useLocalization()
const date = new Date()
return (
<div>
<p>Date: {formatDate(date)}</p>
@@ -59,19 +59,10 @@ function MyComponent() {
import { useLocalization } from '@/contexts/LocalizationContext'
function MyComponent() {
const {
language,
setLanguage,
dateFormat,
setDateFormat,
isRTL
} = useLocalization()
return (
<div dir={isRTL ? 'rtl' : 'ltr'}>
Current language: {language}
</div>
)
const { language, setLanguage, dateFormat, setDateFormat, isRTL } =
useLocalization()
return <div dir={isRTL ? 'rtl' : 'ltr'}>Current language: {language}</div>
}
```
@@ -90,6 +81,7 @@ function MyComponent() {
## RTL Support
Languages in the `RTL_LANGUAGES` array automatically get:
- `dir="rtl"` on the document
- RTL-specific CSS styles
- Proper text alignment
@@ -99,6 +91,7 @@ Currently supported RTL languages: Arabic (ar), Hebrew (he), Persian (fa), Urdu
## Date Format Preferences
Users can choose from:
- MM/DD/YYYY (US)
- DD/MM/YYYY (Europe)
- YYYY-MM-DD (ISO)
@@ -113,5 +106,6 @@ Users can choose from:
## First Day of Week
Users can choose:
- Sunday
- Monday

View File

@@ -30,8 +30,6 @@ html {
border-radius: 4px;
}
/* Prevent iOS Safari from auto-zooming on input focus (triggered when font-size < 16px) */
@supports (-webkit-touch-callout: none) {
input,

View File

@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { GetResource } from '../utils/Fetcher'
// Helper to check if we have a valid token
@@ -13,7 +14,7 @@ const isTokenValid = () => {
}
export const useResource = () => {
const { data, isLoading, error, refetch } = useQuery({
const { data, error, isLoading, refetch } = useQuery({
queryKey: ['resource'],
queryFn: async () => {
const response = await GetResource()

View File

@@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { networkManager } from '../hooks/NetworkManager'
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'

View File

@@ -1,4 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { GetThingHistory } from '../utils/Fetcher'
export const useThingHistory = (thingId, limit = 10) => {

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClearChoreTimer,
DeleteTimeSession,
@@ -56,7 +57,7 @@ export const useUpdateTimeSession = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, sessionId, sessionData }) =>
mutationFn: ({ choreId, sessionData, sessionId }) =>
UpdateTimeSession(choreId, sessionId, sessionData),
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])

View File

@@ -152,7 +152,7 @@ export const GlobalSearchProvider = ({ children }) => {
useEffect(() => {
const onKeyDown = event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
isOpen ? closeSearch() : openSearch()
}

View File

@@ -1,6 +1,8 @@
import {
AddRounded,
ArchiveOutlined,
CheckCircleOutline,
FilterAltOutlined,
FolderOutlined,
HistoryRounded,
InboxOutlined,
@@ -8,6 +10,8 @@ import {
PersonOutline,
SearchRounded,
SettingsOutlined,
TollOutlined,
WidgetsOutlined,
} from '@mui/icons-material'
import {
Box,
@@ -56,29 +60,106 @@ const buildQuickActions = t => [
provider: 'actions',
title: t('search.actions.createTask'),
subtitle: t('search.actions.quickAction'),
route: '/chores/create',
keywords: 'new task chore add create',
// Reuses the widget deep-link param so this lands on the task list with the
// quick-add modal open, instead of the full create page.
route: '/chores?add_task=1',
},
{
id: 'action:tasks',
id: 'action:create-label',
provider: 'actions',
title: t('search.actions.viewAllTasks'),
subtitle: t('search.actions.navigation'),
route: '/chores',
title: t('search.actions.createLabel'),
subtitle: t('search.actions.quickAction'),
keywords: 'new label tag add create',
route: '/labels?create=1',
},
{
id: 'action:archived',
id: 'action:create-project',
provider: 'actions',
title: t('search.actions.viewArchivedTasks'),
subtitle: t('search.actions.navigation'),
route: '/archived',
title: t('search.actions.createProject'),
subtitle: t('search.actions.quickAction'),
keywords: 'new project folder add create',
route: '/projects?create=1',
},
{
id: 'action:settings',
id: 'action:create-filter',
provider: 'actions',
title: t('search.actions.openSettings'),
subtitle: t('search.actions.navigation'),
route: '/settings',
title: t('search.actions.createFilter'),
subtitle: t('search.actions.quickAction'),
keywords: 'new filter view saved search add create',
route: '/filters?create=1',
},
// Every destination in the nav drawer is reachable from here, so the palette
// is a complete way to move around the app without opening the drawer.
...[
{
id: 'action:tasks',
title: t('search.actions.viewAllTasks'),
keywords: 'tasks chores list all open',
route: '/chores',
icon: <InboxOutlined />,
},
{
id: 'action:archived',
title: t('search.actions.viewArchivedTasks'),
keywords: 'archive archived tasks completed open',
route: '/archived',
icon: <ArchiveOutlined />,
},
{
id: 'action:things',
title: t('search.actions.viewThings'),
keywords: 'things devices sensors trackers state open',
route: '/things',
icon: <WidgetsOutlined />,
},
{
id: 'action:labels',
title: t('search.actions.viewLabels'),
keywords: 'labels tags open',
route: '/labels',
icon: <LabelOutlined />,
},
{
id: 'action:projects',
title: t('search.actions.viewProjects'),
keywords: 'projects folders groups open',
route: '/projects',
icon: <FolderOutlined />,
},
{
id: 'action:filters',
title: t('search.actions.viewFilters'),
keywords: 'filters saved views open',
route: '/filters',
icon: <FilterAltOutlined />,
},
{
id: 'action:activities',
title: t('search.actions.viewActivities'),
keywords: 'activities history timeline log open',
route: '/activities',
icon: <HistoryRounded />,
},
{
id: 'action:points',
title: t('search.actions.viewPoints'),
keywords: 'points rewards score leaderboard open',
route: '/points',
icon: <TollOutlined />,
},
{
id: 'action:settings',
title: t('search.actions.openSettings'),
keywords: 'settings preferences configuration open',
route: '/settings',
icon: <SettingsOutlined />,
},
].map(action => ({
...action,
provider: 'actions',
subtitle: t('search.actions.navigation'),
})),
]
const readRecents = () => {
@@ -192,6 +273,22 @@ const GlobalSearchPalette = ({
const [recents] = useState(readRecents)
const selectedResultRef = useRef(null)
const quickActions = useMemo(() => buildQuickActions(t), [t])
const quickActionIndex = useMemo(
() =>
new Fuse(quickActions, {
threshold: 0.38,
distance: 120,
ignoreLocation: true,
includeScore: true,
keys: [
{ name: 'title', weight: 0.7 },
{ name: 'keywords', weight: 0.3 },
],
}),
[quickActions],
)
const searchIndexes = useMemo(
() =>
new Map(
@@ -227,7 +324,7 @@ const GlobalSearchPalette = ({
const recentResults = recents
.map(item => currentById.get(item.id) || item)
.filter(item => item.provider !== 'history' || currentById.has(item.id))
return [...recentResults, ...buildQuickActions(t)]
return [...recentResults, ...quickActions]
}
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
@@ -250,15 +347,40 @@ const GlobalSearchPalette = ({
})
.sort((a, b) => a.score - b.score),
)
grouped.push({
id: 'action:filter-tasks',
provider: 'actions',
title: t('search.actions.filterTasks', { query: query.trim() }),
subtitle: t('search.actions.filterTasksSubtitle'),
route: `/chores?search=${encodeURIComponent(query.trim())}`,
})
return grouped
}, [documents, query, recents, searchIndexes, t])
const actionMatches = (
quickActionIndex.search(normalized, { limit: 4 }) || []
)
.map(match => ({ ...match.item, score: match.score ?? 1 }))
.sort((a, b) => a.score - b.score)
// An action whose title the query starts spelling out ("create la…") is
// what the person is after, so it leads. Anything matched only through its
// keywords stays below the real content it shares words with.
const leadingActions = actionMatches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized),
)
const trailingActions = actionMatches.filter(
action => !leadingActions.includes(action),
)
return [
...leadingActions,
...grouped,
...trailingActions,
{
id: 'action:filter-tasks',
provider: 'actions',
title: t('search.actions.filterTasks', { query: query.trim() }),
subtitle: t('search.actions.filterTasksSubtitle'),
route: `/chores?search=${encodeURIComponent(query.trim())}`,
},
]
}, [documents, query, quickActionIndex, recents, searchIndexes, t])
// Everything except the always-present "filter the task list" fallback.
const matchCount = results.filter(
result => result.id !== 'action:filter-tasks',
).length
useEffect(() => {
selectedResultRef.current?.scrollIntoView({
@@ -339,7 +461,7 @@ const GlobalSearchPalette = ({
pb: 'var(--safe-area-inset-bottom, 0px)',
}}
>
{!isLoading && query.trim() && results.length === 1 && (
{!isLoading && query.trim() && matchCount === 0 && (
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
<InboxOutlined
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
@@ -393,7 +515,7 @@ const GlobalSearchPalette = ({
<ListItemDecorator
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
>
{ICONS[result.provider]}
{result.icon || ICONS[result.provider]}
</ListItemDecorator>
<ListItemContent>
<Typography
@@ -434,9 +556,7 @@ const GlobalSearchPalette = ({
<Typography level='body-xs'> {t('search.footer.open')}</Typography>
<Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim()
? t('search.footer.results', {
count: Math.max(0, results.length - 1),
})
? t('search.footer.results', { count: matchCount })
: t('search.footer.typeToSearch')}
</Typography>
</Box>

View File

@@ -1,13 +1,5 @@
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
import { stripHtml } from '../utils/Helpers'
const HISTORY_STATUS = {
0: 'in progress',
@@ -125,7 +117,7 @@ registerSearchProvider({
title: label.name || 'Untitled label',
subtitle: 'Label',
keywords: 'tag label',
route: '/labels',
route: `/labels/${label.id}`,
color: label.color,
}),
),

View File

@@ -22,7 +22,9 @@ export function isCacheEnabled() {
export function setCacheEnabled(enabled) {
try {
localStorage.setItem(ENABLED_KEY, String(enabled))
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
export function hashContent(content) {
@@ -56,7 +58,9 @@ export function setCached(hash, value) {
index.push(hash)
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
}
} catch { /* storage full, ignore */ }
} catch {
/* storage full, ignore */
}
}
export function getCacheStats() {
@@ -66,7 +70,15 @@ export function getCacheStats() {
export function clearCache() {
const index = getIndex()
index.forEach(h => {
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
try {
localStorage.removeItem(ENTRY_PREFIX + h)
} catch {
/* ignore */
}
})
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
try {
localStorage.removeItem(INDEX_KEY)
} catch {
/* ignore */
}
}

View File

@@ -249,13 +249,28 @@ export const submitErrorReport = async ({
description,
report,
}) => {
// The relay rejects reports without an error. Manual bug reports have no
// thrown Error, so mark only the submitted copy while preserving the local
// diagnostics as a manual report.
const submittedReport =
report.kind === 'bug'
? {
...report,
error: {
...report.error,
name: 'ManualBugReport',
message: 'Submitted manually from the app',
},
}
: report
const payload = {
source: 'donetick-app',
kind: report.kind === 'bug' ? 'bug-report' : 'error-report',
kind: 'error-report',
reportId: report.reportId,
description: description?.trim() || null,
contactEmail: contactEmail?.trim() || null,
report,
report: submittedReport,
}
// Enforced here, not only in the UI, so no future caller can relay a

View File

@@ -1,4 +1,5 @@
import { Capacitor } from '@capacitor/core'
import { getCached, hashContent, setCached } from './AIPromptCache'
// Native-only local AI service using @capacitor/local-llm.
@@ -74,14 +75,19 @@ class LocalAIService {
await this.warmup()
try {
const { LocalLLM } = await import('@capacitor/local-llm')
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
const { text: out } = await LocalLLM.prompt({
prompt: text,
sessionId: this._sessionId,
})
return out?.trim() || null
} finally {
try {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
}
@@ -99,7 +105,9 @@ class LocalAIService {
try {
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
const userMsg = messages.find(m => m.role === 'user')?.content || ''
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
const result = await this._nativePrompt(
`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`,
)
if (result) setCached(cacheHash, result)
return result
} catch (e) {
@@ -122,7 +130,10 @@ class LocalAIService {
try {
await this.warmup()
const { LocalLLM } = await import('@capacitor/local-llm')
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
const { text } = await LocalLLM.prompt({
prompt,
sessionId: this._sessionId,
})
const result = text?.trim() || null
if (result) setCached(cacheHash, result)
return result
@@ -133,7 +144,9 @@ class LocalAIService {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
}
}

View File

@@ -42,7 +42,10 @@ export const decodeNdefUrl = record => {
// Starts a native NFC write session. Calls onWaiting once scanning is active,
// then onSuccess or onError when the write completes. Returns a cancel function.
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
export const startNativeNFCWrite = async (
url,
{ onError, onSuccess, onWaiting },
) => {
let listener = null
let done = false
@@ -86,7 +89,7 @@ export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
// NDEF record is found, or onError on failure. Returns a cancel function.
export const startNativeScan = async ({ onTag, onError }) => {
export const startNativeScan = async ({ onError, onTag }) => {
let listener = null
let done = false

View File

@@ -113,9 +113,8 @@ class VoiceInputService {
async isSupported() {
if (this.isNative) {
try {
const { SpeechRecognition } = await import(
'@capacitor-community/speech-recognition'
)
const { SpeechRecognition } =
await import('@capacitor-community/speech-recognition')
const { available } = await SpeechRecognition.available()
return !!available
} catch {
@@ -134,9 +133,8 @@ class VoiceInputService {
return 'granted'
}
try {
const { SpeechRecognition } = await import(
'@capacitor-community/speech-recognition'
)
const { SpeechRecognition } =
await import('@capacitor-community/speech-recognition')
const current = await SpeechRecognition.checkPermissions()
if (current.speechRecognition === 'granted') return 'granted'
const res = await SpeechRecognition.requestPermissions()
@@ -189,9 +187,8 @@ class VoiceInputService {
if (this.isNative) {
let SpeechRecognition
try {
;({ SpeechRecognition } = await import(
'@capacitor-community/speech-recognition'
))
;({ SpeechRecognition } =
await import('@capacitor-community/speech-recognition'))
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
} catch {
// recognizer may already be stopped
@@ -282,9 +279,8 @@ class VoiceInputService {
}
async _startNative() {
const { SpeechRecognition } = await import(
'@capacitor-community/speech-recognition'
)
const { SpeechRecognition } =
await import('@capacitor-community/speech-recognition')
await SpeechRecognition.removeAllListeners()
await SpeechRecognition.addListener('partialResults', ({ matches }) => {
@@ -342,9 +338,8 @@ class VoiceInputService {
}
async _doRestartNative() {
const { SpeechRecognition } = await import(
'@capacitor-community/speech-recognition'
)
const { SpeechRecognition } =
await import('@capacitor-community/speech-recognition')
try {
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
} catch {

View File

@@ -1,4 +1,5 @@
import { Capacitor, registerPlugin } from '@capacitor/core'
import { apiClient } from '../utils/ApiClient'
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and

View File

@@ -82,11 +82,15 @@
/* Height utilities that account for safe areas */
.min-h-screen-safe {
min-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
min-height: calc(
100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom)
);
}
.h-screen-safe {
height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
height: calc(
100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom)
);
}
/* Top positioning that accounts for safe area */

View File

@@ -19,7 +19,11 @@ const allMonths = [
* @param {Object} chore - The chore object (needed for nextDueDate null check)
* @returns {string} The formatted due date text
*/
export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') => {
export const getDueDateChipText = (
nextDueDate,
chore,
timeFormat = 'h:mm A',
) => {
if (chore?.nextDueDate === null || nextDueDate === null) return 'No Due Date'
const dueDate = moment(nextDueDate)
@@ -34,16 +38,22 @@ export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') =>
sameElse: `MMM D ${timeFormat}`,
}
// if time is 23:59:59, treat as end-of-day (date only, no specific time)
if (dueDate.hours() === 23 && dueDate.minutes() === 59 && dueDate.seconds() === 59) {
if (
dueDate.hours() === 23 &&
dueDate.minutes() === 59 &&
dueDate.seconds() === 59
) {
if (diff < 0) {
// For overdue dates, show calendar format for recent dates
const absDiff = Math.abs(diff)
if (absDiff <= 48) {
return (
'Overdue ' +
moment(nextDueDate).calendar(null, calendarFormat).split(' ')[0].toLowerCase()
moment(nextDueDate)
.calendar(null, calendarFormat)
.split(' ')[0]
.toLowerCase()
)
}
return 'Overdue ' + dueDate.fromNow()

View File

@@ -1,4 +1,5 @@
import moment from 'moment'
import { TASK_COLOR } from './Colors.jsx'
const priorityOrder = [1, 2, 3, 4, 0]
@@ -213,7 +214,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
}
case 'due_date': {
var { dateGroups: dueDateGroups, anytime: dueAnytime } =
var { anytime: dueAnytime, dateGroups: dueDateGroups } =
buildActualDateGroups(chores)
groups = [...dueDateGroups]
if (dueAnytime.length > 0) {

View File

@@ -1,10 +1,6 @@
import moment from 'moment'
export const createDateFormatter = (
dateFormat,
timeFormat,
firstDayOfWeek,
) => {
export const createDateFormatter = (dateFormat, timeFormat, firstDayOfWeek) => {
moment.updateLocale('en', {
week: {
dow: firstDayOfWeek,

View File

@@ -740,7 +740,7 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
const UploadChoreAttachment = (
file,
entityType,
{ entityId, draftId } = {},
{ draftId, entityId } = {},
) => {
const formData = new FormData()
formData.append('file', file)
@@ -986,11 +986,6 @@ const TrackFilterUsage = id => {
export {
AcceptCircleMemberRequest,
DeleteChoreAttachment,
DeleteDraftAttachment,
GetChoreAttachments,
SignAssetURL,
UploadChoreAttachment,
ApproveChore,
ArchiveChore,
CancelSubscription,
@@ -1010,8 +1005,10 @@ export {
CreateThing,
DeleteChildUser,
DeleteChore,
DeleteChoreAttachment,
DeleteChoreHistory,
DeleteCircleMember,
DeleteDraftAttachment,
DeleteFilter,
DeleteLabel,
DeleteLongLiveToken,
@@ -1024,6 +1021,7 @@ export {
GetAllUsers,
GetArchivedChores,
GetChildUsers,
GetChoreAttachments,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
@@ -1069,6 +1067,7 @@ export {
SaveChore,
SaveThing,
SetupMFA,
SignAssetURL,
signUp,
SkipChore,
StartChore,
@@ -1091,5 +1090,6 @@ export {
UpdateThingState,
UpdateTimeSession,
UpdateUserDetails,
UploadChoreAttachment,
VerifyMFA,
}

View File

@@ -13,7 +13,7 @@
* @returns {boolean} - Whether the chore matches the condition
*/
export const evaluateCondition = (chore, condition, context = {}) => {
const { type, operator, value } = condition
const { operator, type, value } = condition
switch (type) {
case 'assignee':
@@ -343,7 +343,7 @@ export const getFilterOverdueCount = (chores, filter, context = {}) => {
* @returns {Object} - { isValid: boolean, issues: Array }
*/
export const validateFilter = (filter, context = {}) => {
const { members = [], labels = [], projects = [] } = context
const { labels = [], members = [], projects = [] } = context
const issues = []
if (!filter.conditions || filter.conditions.length === 0) {

View File

@@ -1,10 +1,22 @@
import moment from 'moment'
import { apiClient } from './ApiClient'
const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
}
// Turns rich-text/HTML content (task descriptions, notes) into plain text so it
// can be indexed or matched by search.
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
const resolvePhotoURL = url => {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('https')) {
@@ -83,4 +95,5 @@ export {
isPlusAccount,
isSignedUrlExpired,
resolvePhotoURL,
stripHtml,
}

View File

@@ -273,7 +273,7 @@ const patchDescriptionHtml = async (html, meta = {}) => {
const images = extractDescriptionImages(html)
if (images.length === 0) return html
let patched = html
for (const { path, src, rawSrc } of images) {
for (const { path, rawSrc, src } of images) {
try {
const nextSrc = await getImageSrc(path, src, {
kind: 'description',

View File

@@ -1,5 +1,6 @@
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { Capacitor } from '@capacitor/core'
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
const DB_NAME = 'donetick_offline'
@@ -630,7 +631,7 @@ class IndexedDBBackend {
async saveChores(chores) {
if (!chores.length) return
const { tx, store } = await this._tx('cached_chores', 'readwrite')
const { store, tx } = await this._tx('cached_chores', 'readwrite')
for (const chore of chores) {
store.put({
@@ -670,7 +671,7 @@ class IndexedDBBackend {
async deleteChores(ids) {
if (!ids.length) return
const { tx, store } = await this._tx('cached_chores', 'readwrite')
const { store, tx } = await this._tx('cached_chores', 'readwrite')
for (const id of ids) {
store.delete(id)
}
@@ -693,7 +694,7 @@ class IndexedDBBackend {
const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
await this._deletePendingHistoryByChoreIds(choreIds)
// Upsert real entries
const { tx, store } = await this._tx('cached_history', 'readwrite')
const { store, tx } = await this._tx('cached_history', 'readwrite')
for (const entry of entries) {
store.put({
id: entry.id,
@@ -736,7 +737,7 @@ class IndexedDBBackend {
.map(row => row.id)
if (!toDelete.length) return
// Tx 2: delete them
const { tx, store: writeStore } = await this._tx(
const { store: writeStore, tx } = await this._tx(
'cached_history',
'readwrite',
)
@@ -772,7 +773,7 @@ class IndexedDBBackend {
async deleteHistory(ids) {
if (!ids.length) return
const { tx, store } = await this._tx('cached_history', 'readwrite')
const { store, tx } = await this._tx('cached_history', 'readwrite')
for (const id of ids) {
store.delete(id)
}

View File

@@ -194,7 +194,7 @@ class SyncEngine {
break
case CommandType.COMPLETE_CHORE: {
const { id, body, completedDate, performer } = cmd.payload
const { body, completedDate, id, performer } = cmd.payload
response = await MarkChoreComplete(
id,
body || {},
@@ -221,7 +221,7 @@ class SyncEngine {
break
case CommandType.UPDATE_CHORE_HISTORY: {
const { choreId, historyId, historyData } = cmd.payload
const { choreId, historyData, historyId } = cmd.payload
response = await UpdateChoreHistory(choreId, historyId, historyData)
break
}
@@ -233,7 +233,7 @@ class SyncEngine {
}
case CommandType.RESCHEDULE_CHORE: {
const { id, dueDate } = cmd.payload
const { dueDate, id } = cmd.payload
response = await UpdateDueDate(id, dueDate)
break
}

View File

@@ -5,48 +5,52 @@ export const USER_TYPES = {
CHILD: 1,
}
export const isParentUser = (user) => {
export const isParentUser = user => {
if (!user) return false
return user.userType === USER_TYPES.PARENT && !user.parentUserId
}
export const isChildUser = (user) => {
export const isChildUser = user => {
if (!user) return false
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
}
export const canManageChildUsers = (user) => {
export const canManageChildUsers = user => {
return isParentUser(user)
}
export const canCreateChores = (user) => {
export const canCreateChores = user => {
// Both parent and child users can create chores
return user && (isParentUser(user) || isChildUser(user))
}
export const canManageCircle = (user) => {
export const canManageCircle = user => {
// Only parent users can manage circle settings
return isParentUser(user)
}
export const canAccessAdminSettings = (user) => {
export const canAccessAdminSettings = user => {
// Only parent users can access admin settings like API tokens, MFA, etc.
return isParentUser(user)
}
export const getUserDisplayInfo = (user) => {
export const getUserDisplayInfo = user => {
if (!user) return { displayName: '', username: '', userType: 'unknown' }
return {
displayName: user.displayName || user.username,
username: user.username,
userType: isParentUser(user) ? 'parent' : isChildUser(user) ? 'child' : 'unknown',
userType: isParentUser(user)
? 'parent'
: isChildUser(user)
? 'child'
: 'unknown',
parentUserId: user.parentUserId,
circleID: user.circleID,
}
}
export const getChildUsernameFromCombined = (combinedUsername) => {
export const getChildUsernameFromCombined = combinedUsername => {
// Extract child name from format: parent_child
const parts = combinedUsername.split('_')
if (parts.length >= 2) {
@@ -55,7 +59,7 @@ export const getChildUsernameFromCombined = (combinedUsername) => {
return combinedUsername
}
export const getParentUsernameFromCombined = (combinedUsername) => {
export const getParentUsernameFromCombined = combinedUsername => {
// Extract parent name from format: parent_child
const parts = combinedUsername.split('_')
return parts[0] || combinedUsername
@@ -63,4 +67,4 @@ export const getParentUsernameFromCombined = (combinedUsername) => {
export const buildChildUsername = (parentUsername, childName) => {
return `${parentUsername}_${childName}`
}
}

View File

@@ -13,11 +13,12 @@ import {
} from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { authButtonSx, authInputSx } from './authStyles'
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
export const AuthField = ({ label, error, helper, children, ...formProps }) => (
export const AuthField = ({ children, error, helper, label, ...formProps }) => (
<FormControl error={Boolean(error)} {...formProps}>
<FormLabel sx={labelSx}>{label}</FormLabel>
{children}
@@ -34,16 +35,16 @@ export const AuthField = ({ label, error, helper, children, ...formProps }) => (
</FormControl>
)
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
export const AuthTextField = ({ error, helper, label, sx, ...inputProps }) => (
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
</AuthField>
)
export const AuthPasswordField = ({
label,
error,
helper,
label,
sx,
...inputProps
}) => {
@@ -92,7 +93,7 @@ export const AuthSubmitButton = ({ children, sx, ...props }) => (
</Button>
)
export const SocialButton = ({ icon, children, sx, ...props }) => (
export const SocialButton = ({ children, icon, sx, ...props }) => (
<Button
type='button'
size='lg'

View File

@@ -1,5 +1,6 @@
import { Capacitor } from '@capacitor/core'
import { Box, Sheet, Typography } from '@mui/joy'
import Logo from '../../Logo'
/**
@@ -8,18 +9,18 @@ import Logo from '../../Logo'
* its own safe-area padding (the top inset is already reserved by NavBar).
*/
const AuthShell = ({
title,
subtitle,
action,
children,
footer,
logoSize = 48,
showLogo = !Capacitor.isNativePlatform(),
subtitle,
// In the app the user already came through the app icon and the Get Started
// mark, so repeating it here is noise. On the web these routes are the first
// thing a visitor sees — often on a self-hosted domain, and with no navbar —
// so the mark is the only thing identifying the app. Views reached from an
// emailed link override this to always show it.
showLogo = !Capacitor.isNativePlatform(),
title,
}) => {
return (
<Box

View File

@@ -1,16 +1,16 @@
import { Box, Button, LinearProgress } from '@mui/joy'
import { useEffect, useState } from 'react'
import { Capacitor } from '@capacitor/core'
import { Box, Button, LinearProgress } from '@mui/joy'
import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { saveTokens } from '../../utils/TokenStorage'
import { useTranslation } from 'react-i18next'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal'

View File

@@ -3,6 +3,7 @@ import { Box, Button, Link, Typography } from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher'
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'

View File

@@ -4,7 +4,9 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import WifiIcon from '@mui/icons-material/Wifi'
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config'
import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/ApiClient'
@@ -12,7 +14,6 @@ import { offlineDB } from '../../utils/OfflineDB'
import { AuthSubmitButton, AuthTextField } from './AuthFields'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import { useTranslation } from 'react-i18next'
const CONNECTION_TIMEOUT_MS = 8000
@@ -106,8 +107,7 @@ const LoginSettings = () => {
) {
return {
ok: false,
message:
t('server.dnsFailed'),
message: t('server.dnsFailed'),
}
}
return {
@@ -119,16 +119,14 @@ const LoginSettings = () => {
// no-cors also timed out → server/host truly unreachable
return {
ok: false,
message:
t('server.unreachable'),
message: t('server.unreachable'),
}
}
// Fallback (should rarely hit)
return {
ok: false,
message:
t('server.unreachable'),
message: t('server.unreachable'),
}
}
}
@@ -145,9 +143,7 @@ const LoginSettings = () => {
if (!isValidURL(trimmedURL)) {
setStatus('error')
setErrorMessage(
t('server.invalidUrl'),
)
setErrorMessage(t('server.invalidUrl'))
return
}

View File

@@ -35,7 +35,7 @@ import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal'
const SegmentedControl = ({ value, onChange, options }) => (
const SegmentedControl = ({ onChange, options, value }) => (
<Box
role='tablist'
sx={{
@@ -582,7 +582,7 @@ const LoginView = () => {
discoveryDocs='claims_supported'
access_type='online'
isOnlyGetToken={true}
onResolve={({ provider, data }) => {
onResolve={({ data, provider }) => {
loggedWithProvider(provider, data)
}}
onReject={() => {

View File

@@ -1,18 +1,18 @@
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { VerifyMFA } from '../../utils/Fetcher'
import { authInputSx } from './authStyles'
import { useTranslation } from 'react-i18next'
const MFAVerificationModal = ({
open,
onClose,
sessionToken,
onSuccess,
onError,
onSuccess,
open,
sessionToken,
}) => {
const { t } = useTranslation('auth')
const [verificationCode, setVerificationCode] = useState('')
@@ -38,8 +38,7 @@ const MFAVerificationModal = ({
onSuccess(data)
} else {
const errorData = await response.json()
const message =
errorData.message || t('mfaModal.invalidCode')
const message = errorData.message || t('mfaModal.invalidCode')
setError(message)
onError?.(message)
}
@@ -77,9 +76,7 @@ const MFAVerificationModal = ({
size='md'
title={t('mfaModal.title')}
description={
isBackupCode
? t('mfaModal.backupHint')
: t('mfaModal.codeHint')
isBackupCode ? t('mfaModal.backupHint') : t('mfaModal.codeHint')
}
closeOnBackdrop={!loading}
closeOnEscape={!loading}
@@ -112,7 +109,9 @@ const MFAVerificationModal = ({
<Input
id='mfa-code'
size='lg'
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'}
placeholder={
isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'
}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -39,8 +39,7 @@ const SignupView = () => {
if (!result.success) {
showError({
title: 'Almost there',
message:
t('signupSignInFailed'),
message: t('signupSignInFailed'),
})
Navigate('/login')
return

View File

@@ -1,3 +1,4 @@
import { Capacitor } from '@capacitor/core'
import {
Archive,
AttachFile,
@@ -7,7 +8,7 @@ import {
Edit,
History,
HourglassEmpty,
LowPriority,
MoreVert,
OpenInFull,
PeopleAlt,
Person,
@@ -25,18 +26,13 @@ import {
Checkbox,
Chip,
Container,
Dropdown,
FormControl,
Grid,
IconButton,
Input,
Menu,
MenuButton,
MenuItem,
Sheet,
Typography,
} from '@mui/joy'
import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
@@ -45,6 +41,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreDetails,
@@ -68,27 +65,39 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import {
ApproveChore,
ArchiveChore,
DeleteChore,
GetChoreDetailById,
MarkChoreComplete,
NudgeChore,
RejectChore,
SaveChore,
SkipChore,
UnArchiveChore,
UndoChoreAction,
UpdateChoreAssignee,
UpdateChorePriority,
UpdateDueDate,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import ChoreActionMenu from '../components/ChoreActionMenu'
import DueDatePickerModal, {
combineDueDate,
splitDueDate,
} from '../components/DueDatePickerModal'
import LoadingComponent from '../components/Loading.jsx'
import PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
@@ -106,6 +115,11 @@ const decodeHtmlEntities = value => {
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
const getNFCUrl = choreId =>
Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios'
? `donetick://chores/${choreId}`
: `${window.location.origin}/chores/${choreId}`
const ChoreView = () => {
const { t } = useTranslation('chores')
const { fmt } = useLocalization()
@@ -116,7 +130,7 @@ const ChoreView = () => {
const { choreId } = useParams()
const [note, setNote] = useState(null)
const queryClient = useQueryClient()
const { showSuccess, showError, showUndo } = useNotification()
const { showError, showSuccess, showUndo } = useNotification()
const [searchParams] = useSearchParams()
@@ -124,7 +138,7 @@ const ChoreView = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false,
})
const [chorePriority, setChorePriority] = useState(null)
const [activeModal, setActiveModal] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
@@ -165,7 +179,6 @@ const ChoreView = () => {
return
}
setChore(choreData.res)
setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res)
@@ -236,7 +249,7 @@ const ChoreView = () => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(() => {
setChorePriority(priority)
setChore(prev => ({ ...prev, priority: priority.value }))
queryClient.invalidateQueries(['chores'])
})
}
@@ -280,7 +293,7 @@ const ChoreView = () => {
message: t('choreView.taskCompletionUndone'),
})
} else {
throw new Error('Failed to undo')
throw new Error(t('choreView.unableUndo'))
}
} catch (error) {
showError({
@@ -318,7 +331,7 @@ const ChoreView = () => {
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — completion will sync when back online",
message: t('choreView.offlineComplete'),
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
@@ -327,7 +340,7 @@ const ChoreView = () => {
} else {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to complete task',
message: error?.message || t('choreView.unableComplete'),
})
}
}
@@ -356,7 +369,7 @@ const ChoreView = () => {
message: t('choreView.taskSkipUndone'),
})
} else {
throw new Error('Failed to undo')
throw new Error(t('choreView.unableUndo'))
}
} catch (error) {
showError({
@@ -376,7 +389,7 @@ const ChoreView = () => {
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — skip will sync when back online",
message: t('choreView.offlineSkip'),
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
@@ -385,7 +398,7 @@ const ChoreView = () => {
} else {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to skip task',
message: error?.message || t('choreView.unableSkip'),
})
}
}
@@ -411,7 +424,7 @@ const ChoreView = () => {
setChore(startedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — start will sync when back online",
message: t('choreView.offlineStart'),
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
@@ -423,7 +436,7 @@ const ChoreView = () => {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to start task',
message: error?.message || t('choreView.unableStart'),
})
},
})
@@ -450,7 +463,7 @@ const ChoreView = () => {
setChore(pausedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — pause will sync when back online",
message: t('choreView.offlinePause'),
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
@@ -462,7 +475,7 @@ const ChoreView = () => {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to pause task',
message: error?.message || t('choreView.unablePause'),
})
},
})
@@ -566,7 +579,7 @@ const ChoreView = () => {
setChore({ ...chore, isActive: true })
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — restore will sync when back online",
message: t('choreView.offlineRestore'),
undoAction: async () => {
await commandQueue.cancel(cmdId)
await offlineDB.saveChores([{ ...chore, isActive: false }])
@@ -576,13 +589,175 @@ const ChoreView = () => {
})
} else {
showError({
title: 'Failed to restore',
message: error.message || 'Unable to restore task',
title: t('choreView.restoreFailed'),
message: error.message || t('choreView.unableRestore'),
})
}
}
}
const confirmSkipTask = () => {
setConfirmModelConfig({
isOpen: true,
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}
const handleArchiveChore = async () => {
try {
const response = await ArchiveChore(choreId)
if (response.ok) {
await offlineDB.saveChores([{ ...chore, isActive: false }])
setChore({ ...chore, isActive: false })
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to archive',
message: error?.message || 'Unable to archive task',
})
}
}
const confirmDeleteChore = () => {
setConfirmModelConfig({
isOpen: true,
title: 'Delete task',
message: 'Are you sure you want to delete this task?',
confirmText: 'Delete',
cancelText: t('choreView.cancel'),
onClose: async confirmed => {
setConfirmModelConfig({})
if (!confirmed) return
try {
const response = await DeleteChore(choreId)
if (response.ok) {
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Deleted',
message: 'The task has been deleted successfully.',
})
navigate('/chores')
}
} catch (error) {
showError({
title: 'Failed to delete',
message: error?.message || 'Unable to delete task',
})
}
},
})
}
const handleDueDateChange = async newDate => {
try {
const response = await UpdateDueDate(choreId, newDate)
if (response.ok) {
setChore(prev => ({ ...prev, nextDueDate: newDate }))
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to reschedule',
message: error?.message || 'Unable to change the due date',
})
}
}
const handleMoveToProject = async project => {
const projectId = project?.id ?? null
try {
const response = await SaveChore({ ...chore, projectId })
if (response.ok) {
setChore(prev => ({ ...prev, projectId }))
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Moved',
message: `Task moved to ${project?.name || 'Default Project'}.`,
})
}
} catch (error) {
showError({
title: 'Failed to move task',
message: error?.message || 'Unable to move task to project',
})
}
}
const handleNudge = async ({ message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (!response.ok) {
throw new Error('Failed to send nudge')
}
const data = await response.json()
showSuccess({
title: 'Nudge Sent!',
message: data.message || 'Nudge sent successfully',
})
} catch (error) {
showError({
title: 'Failed to Send Nudge',
message: error?.message || 'Unable to send nudge at this time',
})
}
}
const handleAssigneeChange = async assigneeId => {
try {
const response = await UpdateChoreAssignee(choreId, assigneeId)
if (response.ok) {
const data = await response.json()
setChore(data.res)
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to delegate',
message: error?.message || 'Unable to change the assignee',
})
}
}
// Actions the menu raises that ChoreView owns; the rest of its items either
// navigate on their own or come in through the dedicated callbacks.
const handleMenuAction = (type, _chore, extraData) => {
switch (type) {
case 'skip':
confirmSkipTask()
break
case 'archive':
handleArchiveChore()
break
case 'unarchive':
handleUnarchiveChore()
break
case 'delete':
confirmDeleteChore()
break
case 'changeDueDate':
handleDueDateChange(extraData?.date?.toISOString() ?? null)
break
case 'moveToProject':
handleMoveToProject(extraData?.project)
break
default:
break
}
}
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
@@ -794,64 +969,6 @@ const ChoreView = () => {
mb: 1,
}}
>
<Dropdown>
<MenuButton
disabled={chore.isActive === false}
color={
chorePriority?.name === 'P1'
? 'danger'
: chorePriority?.name === 'P2'
? 'warning'
: 'neutral'
}
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
width: '100%',
}}
variant='plain'
>
{chorePriority ? chorePriority.icon : <LowPriority />}
{chorePriority ? chorePriority.name : t('choreView.noPriority')}
</MenuButton>
<Menu>
{Priorities.map((priority, index) => (
<MenuItem
sx={{
pr: 1,
py: 1,
}}
key={index}
onClick={() => {
handleUpdatePriority(priority)
}}
color={priority.color}
>
{priority.icon}
{priority.name}
</MenuItem>
))}
<Divider />
<MenuItem
sx={{
pr: 1,
py: 1,
}}
onClick={() => {
handleUpdatePriority({
name: t('choreView.noPriority'),
value: 0,
})
setChorePriority(null)
}}
>
{t('choreView.noPriority')}
</MenuItem>
</Menu>
</Dropdown>
<Button
size='sm'
color='neutral'
@@ -889,6 +1006,38 @@ const ChoreView = () => {
<Edit />
Edit
</Button>
<ChoreActionMenu
chore={chore}
hiddenActions={['view']}
onAction={handleMenuAction}
onNudge={() => setActiveModal('nudge')}
onWriteNFC={() => setActiveModal('writeNFC')}
onCompleteWithNote={() => setNote('')}
onCompleteWithPastDate={() =>
setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00'))
}
onChangeAssignee={() => setActiveModal('changeAssignee')}
onChangeDueDate={() => setActiveModal('changeDueDate')}
onChangePriority={handleUpdatePriority}
onDelete={confirmDeleteChore}
trigger={
<Button
size='sm'
color='neutral'
variant='plain'
fullWidth
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
}}
>
<MoreVert />
{t('choreView.more', 'More')}
</Button>
}
/>
</Box>
{chore.description && (
@@ -1261,21 +1410,7 @@ const ChoreView = () => {
<Button
fullWidth
size='lg'
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
onClick={confirmSkipTask}
disabled={
notInCompletionWindow(chore) || chore.isActive === false
}
@@ -1348,6 +1483,52 @@ const ChoreView = () => {
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
<NoteViewerModal config={noteViewerConfig} />
{activeModal === 'changeDueDate' && (
<DueDatePickerModal
open={true}
title={t('choreView.changeDueDate', 'Change due date')}
{...splitDueDate(chore.nextDueDate)}
onClose={() => setActiveModal(null)}
onApply={parts => {
handleDueDateChange(combineDueDate(parts)?.toISOString() ?? null)
setActiveModal(null)
}}
onRemove={() => {
handleDueDateChange(null)
setActiveModal(null)
}}
/>
)}
{activeModal === 'changeAssignee' && (
<SelectModal
isOpen={true}
options={performers}
displayKey='displayName'
title='Delegate to someone else'
placeholder='Select a performer'
onClose={() => setActiveModal(null)}
onSave={selected => handleAssigneeChange(selected.id)}
/>
)}
{activeModal === 'nudge' && (
<NudgeModal
config={{
isOpen: true,
choreId: chore.id,
onClose: () => setActiveModal(null),
onConfirm: handleNudge,
}}
/>
)}
{activeModal === 'writeNFC' && (
<WriteNFCModal
config={{
isOpen: true,
url: getNFCUrl(choreId),
onClose: () => setActiveModal(null),
}}
/>
)}
<AttachmentBrowserModal
choreId={choreId}
isOpen={attachmentBrowserOpen}

Some files were not shown because too many files have changed in this diff Show More