43 Commits

Author SHA1 Message Date
Mo Tarbin
77f87daeba Release 1.2.48
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-17 01:34:15 -04:00
Mohamad Tarbin
64479f0858 Merge pull request #236 from donetick/0815-fixes
0815 fixes
2026-08-17 01:03:36 -04:00
Mohamad Tarbin
4c235c7d00 Merge branch 'develop' into 0815-fixes 2026-08-17 01:03:29 -04:00
Mo Tarbin
c0d2d5a9ec fix: update terminology from "Report a Bug" to "Report an Issue" across multiple components 2026-08-16 18:03:34 -04:00
Mo Tarbin
dae647a4be feat: enhance global search with navigation mode and updated action hints 2026-08-16 17:56:50 -04:00
Mo Tarbin
f27bb79a07 fix: ensure untranslated strings are skipped in Crowdin configuration 2026-08-16 17:08:44 -04:00
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
Mo Tarbin
253514a6e9 remove unused view mode logic and simplify card rendering in ArchivedTasks and CompactChoreCard 2026-08-16 14:07:54 -04:00
Mo Tarbin
e18c829ae7 feat: integrate PostHog for sourcemap uploads and update dependencies 2026-08-16 13:52:42 -04:00
Mo Tarbin
9329eaf3a9 update consent handling and remove unused error capture logic 2026-08-16 13:13:00 -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
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
231 changed files with 6494 additions and 2289 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 68
versionName "1.2.47"
versionCode 69
versionName "1.2.48"
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
@@ -13,3 +13,4 @@ files:
- source: /public/locales/en/*.json
translation: /public/locales/%two_letters_code%/%original_file_name%
update_option: update_as_unapproved
skip_untranslated_strings: 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

@@ -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 = 68;
CURRENT_PROJECT_VERSION = 69;
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.47;
MARKETING_VERSION = 1.2.48;
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 = 68;
CURRENT_PROJECT_VERSION = 69;
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.47;
MARKETING_VERSION = 1.2.48;
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 = 68;
CURRENT_PROJECT_VERSION = 69;
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.47;
MARKETING_VERSION = 1.2.48;
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 = 68;
CURRENT_PROJECT_VERSION = 69;
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.47;
MARKETING_VERSION = 1.2.48;
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"
}
}
}

630
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",
@@ -42,6 +42,7 @@
"@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
"@posthog/rollup-plugin": "^1.4.9",
"@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@stylistic/eslint-plugin": "^5.10.0",
@@ -106,6 +107,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",
@@ -4595,6 +4597,49 @@
"@posthog/types": "^1.402.2"
}
},
"node_modules/@posthog/cli": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@posthog/cli/-/cli-0.11.2.tgz",
"integrity": "sha512-iqYIl/Q2FfE/+vftlHqGYFz4DRcDon7t28PU6kIefFFUMr0Z4bZrypnxU/Wl4xW/0uUw4fiAKs2tkWmwh3lSDA==",
"hasInstallScript": true,
"hasShrinkwrap": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.1.2"
},
"bin": {
"posthog-cli": "run-posthog-cli.js"
},
"engines": {
"node": ">=14.14",
"npm": ">=6"
}
},
"node_modules/@posthog/cli/node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/@posthog/cli/node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"extraneous": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/@posthog/core": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz",
@@ -4604,6 +4649,29 @@
"@posthog/types": "^1.402.2"
}
},
"node_modules/@posthog/plugin-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@posthog/plugin-utils/-/plugin-utils-1.1.3.tgz",
"integrity": "sha512-bJ7llB5We3NRB6VZfPLAn71Ps8+Q/bEP3T4/Q61CDE8tbkSMiVvWp9cyGRBdBmPnX4X5rT+rEtUZreVPwTvw3g==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6"
}
},
"node_modules/@posthog/rollup-plugin": {
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/@posthog/rollup-plugin/-/rollup-plugin-1.4.9.tgz",
"integrity": "sha512-OzmX630yoyp5+cIeqlr0lLRW4P/ppabK9QC/pe+lqArCohYKp5gjl5fKgYlQ3ruc6Hm+JyF0KuS160O1ZHAvzw==",
"license": "MIT",
"dependencies": {
"@posthog/cli": "~0.11.1",
"@posthog/plugin-utils": "^1.1.3",
"magic-string": "^0.30.17"
},
"peerDependencies": {
"rollup": ">= 4.0.0"
}
},
"node_modules/@posthog/types": {
"version": "1.402.3",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.3.tgz",
@@ -6268,6 +6336,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 +7159,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 +7188,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,
@@ -7868,7 +8077,6 @@
},
"node_modules/detect-libc": {
"version": "2.0.4",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -8101,6 +8309,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 +9390,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 +10879,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 +11157,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 +11552,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 +12079,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 +13943,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 +13981,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 +14589,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 +15346,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.47",
"version": "1.2.48",
"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",
@@ -74,6 +74,7 @@
"@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
"@posthog/rollup-plugin": "^1.4.9",
"@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@stylistic/eslint-plugin": "^5.10.0",
@@ -138,6 +139,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

@@ -30,7 +30,7 @@
"activities": "Activities",
"points": "Points",
"settings": "Settings",
"reportBug": "Report a Bug"
"reportBug": "Report an Issue"
},
"search": {
"title": "Search",
@@ -41,7 +41,8 @@
"recent": "Recent",
"empty": {
"title": "No direct matches",
"subtitle": "You can still filter the task list with this search."
"subtitle": "You can still filter the task list with this search.",
"actionsSubtitle": "No view or action matches. Press backspace to search everything."
},
"groups": {
"tasks": "Tasks",
@@ -56,8 +57,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"
@@ -67,7 +77,14 @@
"open": "Open",
"results_one": "{{count}} result",
"results_other": "{{count}} results",
"typeToSearch": "Type to search"
"typeToSearch": "Type to search",
"slashHint": "/ to jump to a view"
},
"modes": {
"actions": {
"label": "Go to",
"placeholder": "Jump to a view or action"
}
}
},
"policyUpdate": {
@@ -131,7 +148,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 +178,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

@@ -91,8 +91,8 @@
"description": "Tell us how Donetick is working for you or request a feature."
},
"bugReport": {
"title": "Report a Bug",
"description": "Something not working right? Send us the details along with a technical snapshot."
"title": "Report an Issue",
"description": "Tell us what's not working and we'll attach the technical details for you."
}
}
},
@@ -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

@@ -2,7 +2,8 @@ import { Preferences } from '@capacitor/preferences'
// Two independent consent axes, matching the existing onboarding UI
// (HeardAboutView's PrivacyPreferences): "analytics" gates track(), "crash"
// gates captureError(). A self-hosted user can opt into one without the other.
// gates captureException(). A self-hosted user can opt into one without the
// other.
const CONSENT_KEYS = {
analytics: 'analytics_consent',
crash: 'analytics_crash_consent',

View File

@@ -79,12 +79,6 @@ export const EVENT_SCHEMAS = {
}
export const ERROR_SCHEMAS = {
api_error: {
http_status: 'string',
method: 'string',
error_code: 'string',
operation: 'string',
},
// No message/stack field here by design — those come from the real Error
// object passed to posthog.captureException() itself, not from this
// sanitized properties bag. This schema only classifies how it was caught.

View File

@@ -142,20 +142,6 @@ export const track = (eventName, properties = {}) => {
posthog.capture(eventName, sanitized)
}
/** Backend/API failures: a normal sanitized event, same as track() — not
* PostHog's Error Tracking product. There's no real Error object here (just
* an HTTP response), so there's no stack trace to gain from captureException. */
export const captureError = (errorType, properties = {}) => {
if (!canSend('crash')) return
const posthog = getClientSync()
if (!posthog) return
const sanitized = sanitizeErrorProperties(errorType, properties)
if (!sanitized) return
posthog.capture(errorType, sanitized)
}
/**
* Frontend crashes only. Uses captureException (not capture) so these land
* on PostHog's Error Tracking page with a genuine message + stack trace —
@@ -176,7 +162,7 @@ export const captureException = (error, properties = {}) => {
let globalHandlersInstalled = false
/** Reports uncaught exceptions and unhandled promise rejections to
* PostHog's Error Tracking, gated by the same crash consent as api_error.
* PostHog's Error Tracking, gated by crash consent.
* Complements, doesn't overlap with, src/views/Error.jsx: that's a React
* Router error-boundary screen for render/loader errors, which React catches
* before they ever reach window.onerror — a different class of failure, with
@@ -206,7 +192,7 @@ export const installGlobalErrorHandlers = () => {
/**
* kind: 'analytics' | 'crash'. Enabling analytics (re-)initializes PostHog
* if needed and sends analytics_enabled; enabling crash-only never talks to
* PostHog by itself (it only unlocks captureError once something reports).
* PostHog by itself (it only unlocks captureException once something crashes).
* Disabling never sends an event and clears identity/queued data.
*/
export const setConsent = async (kind, value, { source } = {}) => {
@@ -236,7 +222,7 @@ export const setConsent = async (kind, value, { source } = {}) => {
} else if (kind === 'crash') {
// Crash reporting alone doesn't need PostHog started with the analytics
// super-properties path, but it does need a live client + identity to
// send captureError() calls through.
// send captureException() calls through.
if (isConfigured() && !getClientSync()) {
await startPosthog()
}

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

@@ -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,6 @@
import { useQuery } from '@tanstack/react-query'
import { setServerVersion } from '../service/DiagnosticsSession'
import { GetResource } from '../utils/Fetcher'
// Helper to check if we have a valid token
@@ -13,10 +15,13 @@ 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()
// The backend only names its build here, so this is also where crash
// reports learn which server version the user was talking to.
setServerVersion(response?.api_version, response?.api_commit)
return response
},
staleTime: 6 * 60 * 60 * 1000, // 6 hours in milliseconds

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,31 +60,121 @@ 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'),
})),
]
// Typing "/" as the first character switches the palette into navigation mode,
// borrowing the slash-menu reflex from Notion and Slack. The sigil itself is
// stripped from the visible query so the mode reads as state (the chip in the
// input) rather than as syntax the person has to keep typing around.
const MODE_SIGIL = '/'
const parseMode = value => {
const text = value || ''
return text.startsWith(MODE_SIGIL)
? { mode: 'actions', term: text.slice(MODE_SIGIL.length).trimStart() }
: { mode: null, term: text }
}
const readRecents = () => {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
@@ -187,11 +281,28 @@ const GlobalSearchPalette = ({
const focusInputRef = useCallback(node => {
if (node) requestAnimationFrame(() => node.focus())
}, [])
const [query, setQuery] = useState(initialQuery || '')
const [query, setQuery] = useState(() => parseMode(initialQuery).term)
const [mode, setMode] = useState(() => parseMode(initialQuery).mode)
const [selectedIndex, setSelectedIndex] = useState(0)
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(
@@ -220,14 +331,40 @@ const GlobalSearchPalette = ({
[documents],
)
const searchActions = useCallback(
normalized => {
const matches = (quickActionIndex.search(normalized, { limit: 8 }) || [])
.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.
const leading = matches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized),
)
return [
...leading,
...matches.filter(action => !leading.includes(action)),
]
},
[quickActionIndex],
)
const results = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase()
// Navigation mode answers only with actions — an empty term lists them all,
// which is how the sigil teaches itself the first time someone hits "/".
if (mode === 'actions') {
return normalized ? searchActions(normalized) : quickActions
}
if (!normalized) {
const currentById = new Map(documents.map(item => [item.id, item]))
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 +387,43 @@ 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])
// Keyword-only action matches stay below the real content they share words
// with; title-prefix matches lead.
const actionMatches = searchActions(normalized).slice(0, 4)
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,
mode,
query,
quickActions,
recents,
searchActions,
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({
@@ -273,8 +438,25 @@ const GlobalSearchPalette = ({
if (presentation === 'modal') onClose()
}
const onQueryChange = value => {
setSelectedIndex(0)
if (!mode) {
const parsed = parseMode(value)
setMode(parsed.mode)
setQuery(parsed.term)
return
}
setQuery(value)
}
const onInputKeyDown = event => {
if (event.key === 'ArrowDown') {
// Backspace on an empty term steps back out of the mode, so the sigil is
// one keystroke to enter and one to leave.
if (event.key === 'Backspace' && mode && !query) {
event.preventDefault()
setMode(null)
setSelectedIndex(0)
} else if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex(index => Math.min(index + 1, results.length - 1))
} else if (event.key === 'ArrowUp') {
@@ -301,13 +483,30 @@ const GlobalSearchPalette = ({
},
}}
value={query}
onChange={event => {
setQuery(event.target.value)
setSelectedIndex(0)
}}
onChange={event => onQueryChange(event.target.value)}
onKeyDown={onInputKeyDown}
placeholder={t('search.placeholder')}
startDecorator={<SearchRounded />}
placeholder={
mode
? t('search.modes.actions.placeholder')
: t('search.placeholder')
}
startDecorator={
mode ? (
<Chip
size='sm'
variant='soft'
color='primary'
onClick={() => {
setMode(null)
setSelectedIndex(0)
}}
>
{t('search.modes.actions.label')}
</Chip>
) : (
<SearchRounded />
)
}
endDecorator={
isLoading ? (
<CircularProgress size='sm' />
@@ -339,14 +538,16 @@ 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 }}
/>
<Typography level='title-md'>{t('search.empty.title')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('search.empty.subtitle')}
{mode
? t('search.empty.actionsSubtitle')
: t('search.empty.subtitle')}
</Typography>
</Box>
)}
@@ -393,7 +594,7 @@ const GlobalSearchPalette = ({
<ListItemDecorator
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
>
{ICONS[result.provider]}
{result.icon || ICONS[result.provider]}
</ListItemDecorator>
<ListItemContent>
<Typography
@@ -432,11 +633,14 @@ const GlobalSearchPalette = ({
{t('search.footer.navigate')}
</Typography>
<Typography level='body-xs'> {t('search.footer.open')}</Typography>
{!mode && (
<Typography level='body-xs'>
{t('search.footer.slashHint')}
</Typography>
)}
<Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim()
? t('search.footer.results', {
count: Math.max(0, results.length - 1),
})
{query.trim() || mode
? 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

@@ -5,7 +5,8 @@
*
* Deliberately dependency-free — ApiClient imports it on the request path, so
* anything imported here would risk a module cycle. Everything is in memory
* and dies with the tab; nothing is persisted.
* and dies with the tab, except the server build, which is remembered across
* launches so a crash before the first API answer still names the backend.
*/
const SESSION_STARTED_AT = Date.now()
@@ -25,6 +26,7 @@ const routeTrail = []
const apiFailures = []
let backgroundedCount = 0
let serverVersion = null
let serverCommit = null
// ---------------------------------------------------------------------------
// Route trail
@@ -67,6 +69,33 @@ export const getPreviousRoute = () =>
// Server identity
// ---------------------------------------------------------------------------
const SERVER_BUILD_KEY = 'diagnostics_server_build'
// A crash on cold start happens before /resource has answered, and that is
// exactly when knowing which backend the user is on matters most. Carrying the
// last known build across launches keeps the report from saying "not reported".
try {
const cached = JSON.parse(localStorage.getItem(SERVER_BUILD_KEY) || 'null')
serverVersion = cached?.version ?? null
serverCommit = cached?.commit ?? null
} catch {
// corrupt or unavailable storage just means we start without a known build
}
const rememberServerBuild = (version, commit) => {
if (!version && !commit) return
serverVersion = version || serverVersion
serverCommit = commit || serverCommit
try {
localStorage.setItem(
SERVER_BUILD_KEY,
JSON.stringify({ version: serverVersion, commit: serverCommit }),
)
} catch {
// storage full or blocked; the in-memory copy still serves this session
}
}
/**
* Picks the server build out of response headers. Costs nothing when the
* server doesn't send them — the field simply stays null.
@@ -74,21 +103,24 @@ export const getPreviousRoute = () =>
export const recordServerVersionFromResponse = response => {
if (serverVersion) return
try {
serverVersion =
rememberServerBuild(
response?.headers?.get?.('x-donetick-version') ||
response?.headers?.get?.('x-api-version') ||
null
response?.headers?.get?.('x-api-version'),
null,
)
} catch {
// headers may be inaccessible on opaque responses; not worth reporting
}
}
export const setServerVersion = version => {
if (version) serverVersion = version
}
/** Authoritative source: what /resource reports about the backend build. */
export const setServerVersion = (version, commit) =>
rememberServerBuild(version, commit)
export const getServerVersion = () => serverVersion
export const getServerCommit = () => serverCommit
// ---------------------------------------------------------------------------
// API failures
// ---------------------------------------------------------------------------
@@ -176,6 +208,7 @@ export const getSessionDiagnostics = async () => {
navigationType: NAVIGATION_TYPE,
backgroundedCount,
serverVersion,
serverCommit,
previousRoute: getPreviousRoute(),
routeTrail: getRouteTrail(),
apiFailures: getApiFailures(),

View File

@@ -153,7 +153,9 @@ export const formatErrorReport = report => {
session.previousRoute ? `Came from: ${session.previousRoute}` : null,
'',
`App: ${app.appVersion} · ${app.platform}${app.isNative ? ' (native)' : ''}`,
`Server: ${session.serverVersion ?? 'not reported'}`,
`Server: ${session.serverVersion ?? 'not reported'}${
session.serverCommit ? ` (${session.serverCommit.slice(0, 8)})` : ''
}`,
`Session: ${formatDuration(session.sessionDurationMs)} active · ${
session.navigationType ?? 'unknown'
} start · backgrounded ${session.backgroundedCount ?? 0}×`,
@@ -249,13 +251,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

@@ -1,10 +1,8 @@
import { Preferences } from '@capacitor/preferences'
import { captureError } from '../analytics'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import {
normalizeEndpoint,
recordApiFailure,
recordServerVersionFromResponse,
} from '../service/DiagnosticsSession'
@@ -230,11 +228,6 @@ class ApiClient {
method: config.method,
status: response.status,
})
captureError('api_error', {
http_status: String(response.status),
method: config.method || 'GET',
operation: normalizeEndpoint(endpoint),
})
}
// 2. Check for 401 (Unauthorized)
@@ -319,11 +312,6 @@ class ApiClient {
if (!externalAbort) {
networkManager.setServerUnreachable()
recordApiFailure({ endpoint, method: config.method, status: 'network' })
captureError('api_error', {
http_status: 'network',
method: config.method || 'GET',
operation: normalizeEndpoint(endpoint),
})
}
console.error('Request failed', error)
throw error

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={() => {

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