51 Commits

Author SHA1 Message Date
Mo Tarbin
b3b3a0e5a8 release 1.2.49
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-18 08:50:46 -04:00
Mohamad Tarbin
a92eeaf149 Merge pull request #237 from donetick/missing-translation
Missing translation Part 1
2026-08-18 08:44:45 -04:00
Mo Tarbin
86989362fe fix: standardize section separators in README.md 2026-08-18 08:41:48 -04:00
Mo Tarbin
cb81749dbf fix: update ESLint ignores and improve i18n audit script readability 2026-08-18 08:23:19 -04:00
Mo Tarbin
0bac419c3a feat: enhance localization and user experience across various components
- Added new localization keys for project management, settings, and onboarding views.
- Updated labels and placeholders in project modal, password change modal, and backup/restore modal for better clarity.
- Improved user feedback messages for password mismatch and backup creation failures.
- Enhanced the timer details view with localized strings for active work, sessions, and distribution.
- Refined the due date picker modal with localized quick date and time options.
- Updated photo task modal and scan panel to include localized text for better accessibility.
- Ensured consistent use of translation function for all user-facing strings.
2026-08-18 01:31:16 -04:00
Mo Tarbin
1fa4748168 feat: integrate i18n for translation in various components
- Updated NativeCancelSubscriptionModal to use translation keys for titles and messages.
- Enhanced CircleSetupView with translation for notifications and UI text.
- Refactored ThingsView to implement translation for notifications, titles, and descriptions.
- Added translation support in AdvancedOptionsSection for labels and descriptions.
- Implemented i18n in RepeatPickerField for various UI elements and labels.
2026-08-18 01:27:57 -04:00
Mo Tarbin
c1318a2cc4 feat: add i18n support to Settings and AddTaskModal components
- Integrated translation functionality using react-i18next in Settings.jsx
- Replaced hardcoded strings with translation keys for better localization support
- Updated AddTaskModal.jsx to include translations for task-related strings
- Enhanced user experience by providing localized messages for various actions and notifications
2026-08-18 01:11:26 -04:00
Mo Tarbin
cc2e656e14 update localization for notifications, colors, and icons across multiple components 2026-08-18 00:06:34 -04:00
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
242 changed files with 9107 additions and 3842 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@v7
- name: Use Node.js 20
uses: actions/setup-node@v7
with:
node-version: 20.x
cache: 'npm'
- name: Install dependencies
run: npm i
- run: npm run build
- run: npm run lint:ci

View File

@@ -1,2 +1,13 @@
LICENSE.md
dist/
build/
vendor/
android/
ios/
resources/
.bundle/
.agents/
.impeccable/
package-lock.json
Gemfile.lock
skills-lock.json

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 70
versionName "1.2.49"
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

@@ -15,7 +15,17 @@ import globals from 'globals'
export default [
{
ignores: ['dist'],
ignores: [
'dist',
'build',
'vendor',
'android',
'ios',
'resources',
'public/assets',
'.agents',
'.impeccable',
],
},
js.configs.recommended,
react.configs.flat.recommended,
@@ -51,11 +61,24 @@ export default [
eslintConfigPrettier, // Disable any rules that conflict with prettier
eslintPluginPrettierRecommended,
{
files: ['*.{js,ts}'],
files: [
'*.{js,mjs,cjs,ts}',
'scripts/**/*.{js,mjs,cjs,ts}',
'workers/**/*.{js,mjs,cjs,ts}',
],
languageOptions: {
globals: globals.node,
},
},
{
files: ['e2e/**/*.{js,mjs}'],
languageOptions: {
globals: {
...globals.node,
...globals.browser,
},
},
},
{
files: ['*.config.{mjs,ts}'],
languageOptions: {
@@ -63,9 +86,9 @@ export default [
sourceType: 'module',
},
rules: {
// Formatting rules (indent, space-infix-ops) are owned by prettier here —
// re-enabling them fights prettier/prettier and makes --fix unable to converge.
eqeqeq: 'error',
indent: ['error', 2],
'space-infix-ops': ['error', { int32Hint: false }],
},
},
{

View File

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

View File

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

View File

@@ -462,12 +462,12 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 70;
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.49;
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 = 70;
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.49;
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 = 70;
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.49;
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 = 70;
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.49;
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.49",
"type": "module",
"engines": {
"node": ">=20.0.0",
@@ -20,8 +20,9 @@
"build-cf": "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",
"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": "eslint && prettier -c . && npm run lint:i18n",
"lint:i18n": "node scripts/i18n-audit.mjs",
"lint:ci": "eslint -f gha && prettier -c . && npm run lint:i18n",
"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 +75,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 +140,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,771 @@
"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}}\"",
"filtersTitle": "Filters",
"sortArchivedDate": "Archived date",
"sortName": "Name",
"sortPriority": "Priority",
"sortDueDate": "Due date",
"noMatchTitle": "No archived tasks match",
"noMatchSearch": "Nothing in the archive matches \"{{term}}\".",
"noMatchFilters": "There are archived tasks, but none fit the filters that are currently on.",
"emptyTitle": "Nothing archived",
"emptyDescription": "Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.",
"backToTasks": "Back to tasks"
},
"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",
"reject": "Reject",
"pending": "Pending",
"schedule": "Schedule",
"nudge": "Nudge"
},
"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)",
"mixed": "Mixed",
"due": "Due",
"dueTitleSet": "Due date on selected tasks: {{value}}",
"dueTitleEmpty": "Set due date on selected tasks",
"presetToday": "Today",
"presetTomorrow": "Tomorrow",
"presetNextWeek": "Next week",
"pickDate": "Pick date…",
"noDueDate": "No due date",
"moveTitle": "Move selected tasks to a project",
"move": "Move",
"defaultProject": "Default Project",
"moreTitle": "Set priority, assignee, or labels",
"more": "More",
"priority": "Priority",
"assignee": "Assignee",
"labels": "Labels",
"labelsHint": "Tap to add · tap again to remove",
"done": "Done",
"noPriority": "None"
},
"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",
"colDue": "Due",
"colChore": "Chore",
"colAssignee": "Assignee",
"colAction": "Action"
},
"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",
"title": "Due Date",
"apply": "Apply",
"remove": "Remove",
"quickDate": "Quick date",
"quickTime": "Quick time",
"nextMonth": "Next month",
"morning": "Morning",
"noon": "Noon",
"afternoon": "Afternoon",
"evening": "Evening",
"night": "Night",
"customTime": "Custom time",
"anytime": "Anytime",
"specificTime": "Specific time"
},
"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",
"retake": "Retake",
"upload": "Upload",
"keepAsAttachment": "Keep photo as attachment",
"altCaptured": "Captured document",
"altProcessing": "Processing",
"taskIdentified": "Task identified"
},
"descriptionPlaceholder": "Enter description...",
"notificationTemplate": {
"onDueDate": "On due date",
"timing": {
"before": "Before",
"ondue": "Due",
"after": "After"
},
"unitName": {
"m": "minutes",
"h": "hours",
"d": "days"
},
"unitShort": {
"m": "Mins",
"h": "Hours",
"d": "Days"
},
"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"
},
"choreEdit": {
"errNameRequired": "Name is required",
"errFrequencyInvalid": "Invalid frequency, the {{unit}} should be > 0",
"errSelectDayOfWeek": "Please select at least one day of the week",
"errSelectDayOccurrence": "Please select at least one day occurrence for the month",
"errSelectMonth": "Please select at least one month",
"errStartDateRequired": "Start date is required",
"errDueDateRequired": "Due date is required",
"errThingTrigger": "Thing trigger is invalid",
"errTitle": "Please resolve the following errors:",
"savedOfflineTitle": "Saved Offline",
"savedOfflineMessage": "Your changes will sync when you are back online.",
"savedTitle": "Chore Saved",
"savedMessage": "Your task has been saved successfully!",
"saveFailedTitle": "Save Failed",
"saveFailedMessage": "Failed to save chore, please try again.",
"uploadFailedTitle": "Upload Failed",
"uploadFailedMessage": "Failed to upload attachment.",
"scanFailedTitle": "Scan Failed",
"scanFailedMessage": "Could not scan the document.",
"scanReadFailedMessage": "Could not read the scanned image.",
"deleteFailedTitle": "Delete Failed",
"deleteAttachmentFailed": "Failed to delete attachment.",
"deleteChoreFailed": "Failed to delete chore: {{error}}",
"deleteChoreTitle": "Delete Chore",
"name": "Name",
"nameDesc": "What is the name of this task?",
"description": "Description",
"descriptionDesc": "What is this task about?",
"priority": "Priority",
"priorityDesc": "How important is this task?",
"noPriority": "No Priority",
"project": "Project",
"projectDesc": "Which project does this task belong to?",
"defaultProject": "Default Project",
"labels": "Labels",
"labelsDesc": "Things to remember about this task or to tag it",
"addNewLabel": "Add New Label",
"subTasks": "Sub Tasks",
"attachments": "Attachments",
"attachmentsDesc": "Files attached to this task",
"uploadFile": "Upload File",
"scan": "Scan",
"assignees": "Assignees",
"assigneesDesc": "Who can do this task?",
"anyone": "Anyone",
"rememberFuture": "Remember for Future Tasks",
"currentlyAssigned": "Currently Assigned To",
"currentlyAssignedDesc": "Who is assigned the next due?",
"noAssigneesPlaceholder": "No Assignees yet can perform this task",
"selectAssigneePlaceholder": "Select an assignee for this task",
"assignStrategy": "Assignment Strategy",
"assignStrategyDesc": "How to pick the next assignee for the following task?",
"strategy": {
"random": "Random",
"least_assigned": "Least Assigned",
"least_completed": "Least Completed",
"keep_last_assigned": "Keep Last Assigned",
"random_except_last_assigned": "Random Except Last Assigned",
"round_robin": "Round Robin",
"no_assignee": "No Assignee"
},
"startDate": "Start Date",
"dueDate": "Due Date",
"triggerDueHint": "Due Date will be set when the trigger of the thing is met",
"giveDueDate": "Give this task a due date",
"giveDueDateHelp": "Task needs to be completed by a specific time",
"startWhen": "When does this task start?",
"dueWhen": "When is the next first time this task is due?",
"setSpecificTime": "Set a specific time",
"dueAtSpecifiedTime": "Task will be due at the specified time",
"dueEndOfDay": "Task will be due at the end of the day (11:59 PM)",
"timeLabel": "Time:",
"taskWindow": "Task Window",
"taskWindowDesc": "Define when this task can be completed and when it expires",
"earliestCompletion": "Set earliest completion time",
"earliestCompletionHelp": "Task becomes available to complete X hours before the due date",
"hoursLabel": "Hours:",
"hoursPlaceholder": "Hours",
"afterDueDate": "after due date",
"schedulingPrefs": "Scheduling Preferences",
"schedulingPrefsDesc": "How to reschedule the next due date?",
"rescheduleFromDue": "Reschedule from due date",
"rescheduleFromDueHelp": "the next task will be scheduled from the original due date, even if the previous task was completed late",
"rescheduleFromCompletion": "Reschedule from completion date",
"rescheduleFromCompletionHelp": "the next task will be scheduled from the actual completion date of the previous task",
"notifications": "Notifications",
"notificationsPlanWarning": "Task notifications are not available in the Basic plan. Upgrade to Plus to receive reminders when tasks are due or completed.",
"notifyForTask": "Notify for this task",
"notifyForTaskHelp": "When should receive notifications for this task",
"notificationSchedule": "Notification Schedule",
"whoToNotify": "Who to Notify",
"allAssignees": "All Assignees",
"allAssigneesHelp": "Notify all assignees",
"specificGroup": "Specific Group",
"specificGroupHelp": "Notify a specific group",
"telegramGroupIdLabel": "Telegram Group ID:",
"telegramGroupIdPlaceholder": "Telegram Group ID",
"taskSettings": "Task Settings:",
"pointsSystem": "Points System",
"assignPoints": "Assign points for completion",
"assignPointsHelp": "Assign points to this task and user will earn points when they completed it",
"pointsLabel": "Points:",
"pointsPlaceholder": "Points",
"approvalRequirement": "Approval Requirement",
"requireApproval": "Require admin approval",
"requireApprovalHelp": "This task will need approval from an admin before being marked as complete",
"privacySettings": "Privacy Settings",
"privacyDesc": "Who can see this task?",
"public": "Public",
"publicHelp": "Everyone in your circle",
"limited": "Limited",
"limitedHelp": "You and others that are assigned to the task",
"limitedDisabledHint": " (No assignees selected, Limited option is disabled)",
"createdBy": "Created by",
"updatedBy": "Updated by",
"archive": "Archive",
"unarchive": "Unarchive",
"create": "Create"
},
"addTask": {
"title": "Create new task",
"defaultProject": "Default Project",
"anyone": "Anyone",
"taskPlaceholder": "Type your task...",
"descriptionChip": "Description",
"subtasksChip": "Subtasks",
"creating": "Creating…",
"createCount": "Create {{count}} Tasks",
"useTask": "Use Task",
"processing": "Processing",
"create": "Create"
},
"repeat": {
"days": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
},
"months": {
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December"
},
"freqType": {
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly",
"adaptive": "Adaptive",
"custom": "Custom"
},
"freqMessage": {
"adaptive": "This chore will be scheduled dynamically based on previous completion dates.",
"custom": "This chore will be scheduled based on a custom frequency."
},
"weekPattern": {
"every_week": "Every week",
"week_of_month": "Specific occurrences in the month"
},
"weekPatternHelp": {
"every_week": "Task repeats every week on selected days",
"week_of_month": "Task repeats on specific day occurrences each month (e.g., 1st Monday, 3rd Friday)"
},
"occurrence": {
"1": "1st occurrence",
"2": "2nd occurrence",
"3": "3rd occurrence",
"4": "4th occurrence",
"last": "Last occurrence"
},
"ordinal": {
"1": "1st",
"2": "2nd",
"3": "3rd",
"4": "4th",
"last": "last"
},
"unit": {
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"repeatOnType": {
"interval": "Interval",
"days_of_the_week": "Days of the week",
"day_of_the_month": "Day of the month"
},
"timeOfDay": "Time of day: ",
"every": "Every: ",
"unitPlaceholder": "Unit",
"selectAll": "Select All",
"unselectAll": "Unselect All",
"selectOccurrences": "Select which occurrences of the selected days:",
"occurrenceExample": "Example: \"1st Monday\" means the first Monday of each month",
"onThe": "on the ",
"ofAboveMonths": "of the above month/s",
"title": "Repeat:",
"repeatThisTask": "Repeat this task",
"repeatThisTaskHelp": "Is this something needed to be done regularly?",
"howOften": "How often should it be repeated?",
"repeatOn": "Repeat on:",
"triggerLabel": "Trigger this task based on a thing state",
"triggerHelp": "Is this something that should be done when a thing state changes?",
"triggerPlanWarning": "Thing-based triggers are not available in the Basic plan. Upgrade to Plus to automatically trigger tasks when device states change.",
"previewEvery": "Every {{days}} at {{time}}",
"previewOccurrence": "Every {{occurrences}} {{days}} of the month at {{time}}",
"defaultTime": "6:00 PM",
"daysShort": {
"monday": "Mon",
"tuesday": "Tue",
"wednesday": "Wed",
"thursday": "Thu",
"friday": "Fri",
"saturday": "Sat",
"sunday": "Sun"
},
"monthsShort": {
"january": "Jan",
"february": "Feb",
"march": "Mar",
"april": "Apr",
"may": "May",
"june": "Jun",
"july": "Jul",
"august": "Aug",
"september": "Sep",
"october": "Oct",
"november": "Nov",
"december": "Dec"
},
"occurrenceShort": {
"1": "1st",
"2": "2nd",
"3": "3rd",
"4": "4th",
"last": "Last"
},
"picker": {
"modalTitle": "Repeat Schedule",
"trigger": "Repeat",
"clearAria": "Clear repeat schedule",
"remove": "Remove",
"apply": "Apply",
"frequency": "Frequency",
"scheduleType": "Schedule type",
"timeOfDay": "Time of day",
"repeatEvery": "Repeat every",
"days": "Days",
"pattern": "Pattern",
"everyWeek": "Every week",
"specificWeeks": "Specific weeks",
"occurrences": "Occurrences",
"months": "Months",
"dayOfMonth": "Day of month"
}
},
"advancedOptions": {
"points": "Points",
"pointsDescription": "Award points for completing this task",
"requireApproval": "Require approval",
"requireApprovalDescription": "Task needs admin sign-off before it can be closed",
"limitedVisibility": "Limited visibility",
"limitedVisibilityDisabled": "Assign someone to enable limited visibility",
"limitedVisibilityDescription": "Only you and assignees can see this task",
"assignStrategy": "Assign strategy",
"assignStrategyDescription": "How to pick the next assignee each recurrence",
"availableFrom": "Available from",
"availableFromDescription": "Hours before the due date the task becomes available",
"strategy": {
"keep_last_assigned": "Keep same assignee",
"random": "Random",
"least_completed": "Least completed",
"round_robin": "Round robin"
},
"hrs": "hrs",
"setDueDateHint": "Set a due date to configure completion window",
"more": "More"
},
"filterBuilder": {
"dueDateOption": {
"isOverdue": "Overdue",
"isDueToday": "Today",
"isDueTomorrow": "Tomorrow",
"isDueThisWeek": "This Week",
"isDueThisMonth": "This Month",
"hasNoDueDate": "No Due Date",
"hasDueDate": "Has Due Date"
},
"status": {
"0": "Active",
"1": "Started",
"2": "In Progress",
"3": "Pending Approval"
},
"include": "Include",
"exclude": "Exclude",
"has": "Has",
"doesntHave": "Doesn't Have",
"assignee": "Assignee",
"createdBy": "Created By",
"statusHeading": "Status",
"priority": "Priority",
"dueDate": "Due Date",
"labels": "Labels",
"projects": "Projects",
"defaultProject": "Default Project",
"points": "Points",
"project": "Project",
"filterFallback": "Filter",
"notPrefix": "Not ",
"customDueDate": "Custom",
"clear": "Clear"
},
"empty": {
"errorTitle": "Can't reach Donetick",
"errorDescription": "Your tasks are safe. We just could not load them right now, check your connection and try again.",
"errorAction": "Try again",
"noTasksTitle": "No tasks yet",
"noTasksDescription": "Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.",
"createTask": "Create a task",
"moreOptions": "More options",
"noMatchTitle": "No tasks match this view",
"noMatchSearch": "Nothing matches \"{{term}}\". Try a different search, or clear what is narrowing the list.",
"noMatchFilters": "You have tasks, but none of them fit the filters that are currently on.",
"clearSearch": "Clear search",
"showEveryone": "Show everyone's tasks",
"clearFilters": "Clear filters",
"projectTitle": "Nothing in {{name}} yet",
"projectDescription": "Tasks you add to this project show up here. Your other tasks are still where you left them.",
"addTaskHere": "Add a task here",
"seeOutsideProjects": "See tasks outside projects",
"noProjectTitle": "No tasks here yet",
"noProjectDescription": "Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.",
"dayFreeDescription": "This day is free. Add a task if you want something to land here.",
"addTask": "Add task",
"assignee": {
"assigned_to_me": "There are tasks here, but none of them are assigned to you. Switch back to everyone to see the rest.",
"available_for_me": "There are tasks here, but none of them are available for you to pick up. Switch back to everyone to see the rest.",
"assigned_to_others": "There are tasks here, but none of them are assigned to someone else. Switch back to everyone to see the rest.",
"assigned_to_me_tasks": "There are tasks here, but none of them are assigned to you. Switch back to everyone to see the rest.",
"created_by_me": "There are tasks here, but none of them are created by you. Switch back to everyone to see the rest."
}
}
}

View File

@@ -9,6 +9,32 @@
"error": "Error",
"success": "Success",
"warning": "Warning",
"colors": {
"Salmon": "Salmon",
"Teal": "Teal",
"Sky Blue": "Sky Blue",
"Grape": "Grape",
"Sunshine": "Sunshine",
"Coral": "Coral",
"Lavender": "Lavender",
"Rose": "Rose",
"Charcoal": "Charcoal",
"Sienna": "Sienna",
"Mint": "Mint",
"Amber": "Amber",
"Cobalt": "Cobalt",
"Emerald": "Emerald",
"Peach": "Peach",
"Ocean": "Ocean",
"Mustard": "Mustard",
"Ruby": "Ruby",
"Periwinkle": "Periwinkle",
"Turquoise": "Turquoise",
"Lime": "Lime",
"Blush": "Blush",
"Ash": "Ash",
"Sand": "Sand"
},
"refresh": "Refresh",
"copy": "Copy",
"copied": "Copied!",
@@ -30,7 +56,7 @@
"activities": "Activities",
"points": "Points",
"settings": "Settings",
"reportBug": "Report a Bug"
"reportBug": "Report an Issue"
},
"search": {
"title": "Search",
@@ -41,7 +67,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 +83,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 +103,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 +174,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 +204,256 @@
"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",
"planFree": "Free",
"planPlus": "Plus",
"planPlusExpiring": "Plus (expires soon)",
"switchUser": "Switch User",
"impersonateUser": "Impersonate User",
"switchToLight": "Switch to Light",
"switchToDark": "Switch to Dark",
"unlockPremium": "Unlock premium features",
"impersonating": "Impersonating",
"toggleThemeAppearance": "Toggle theme appearance",
"upgradeToPlus": "Upgrade to Plus"
},
"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}}",
"onboarding": {
"sources": {
"speak": "Speak",
"snap": "Snap",
"type": "Type"
},
"capture": {
"voiceCaption": "Dates, labels and points from your words",
"deviceCaption": "Read on your device",
"typeCaption": "#labels @people *points as you type",
"trashTitle": "♻️ Take out the trash",
"trashDue": "Due tomorrow",
"trashRepeat": "Every Monday",
"homeLabel": "Home",
"vehicleTitle": "🚗 Vehicle Registration Renewal",
"vehicleDue": "Due Aug 3",
"acTitle": "💨 Change the AC filter",
"acDue": "Due Fri",
"acRepeat": "Every 3 months"
},
"schedule": {
"fromDueDate": "Reschedule from due date",
"fromCompletionDate": "Reschedule from completion date",
"due": "Due",
"done": "Done",
"next": "Next"
},
"nfc": {
"tagOnWasher": "Tag on the washer",
"tapToOpen": "Tap to open",
"swapFilter": "Swap the washer filter",
"done": "Done",
"skip": "Skip",
"startTimer": "Start timer",
"waitingApproval": "{{name}} marked it done · waiting on you",
"approve": "Approve",
"reject": "Reject",
"completed": "Completed",
"note": "Used the delicate cycle — filter needs a clean next time.",
"metaJustNow": "Just now · ★ 5 pts",
"metaLastWeek": "Last week · ★ 5 pts"
},
"circle": {
"kitchenTitle": "Kitchen deep clean",
"kitchenDue": "Due Sat",
"kitchenRepeat": "Every week",
"takesTurns": "Takes turns"
},
"turns": {
"binsTitle": "🗑️ Take bins to the curb",
"binsDue": "Due Tomorrow",
"binsRepeat": "Every week",
"completed": "Completed",
"metaJustNow": "Just now · ★ 5 pts"
},
"widget": {
"schoolForms": "School forms",
"takeOutTrash": "Take out the trash",
"waterPlants": "Water the plants",
"today": "Today",
"left": "{{count}} left"
},
"reminders": {
"trashTitle": "Take out the trash",
"trashBody": "Due in 30 minutes · Bin night",
"finishedTitle": "Amalie finished Kitchen deep clean",
"finishedBody": "Your turn is next Saturday"
},
"things": {
"waterBill": "Water bill",
"acFilter": "AC filter",
"trashDay": "Trash day",
"dogMedicine": "Dog's medicine",
"whoseTurn": "Whose turn to cook",
"monthly": "Monthly",
"everyThreeMonths": "Every 3 months",
"everyMonday": "Every Monday",
"daily": "Daily",
"weekly": "Weekly",
"tagBills": "Bills",
"tagHome": "Home",
"tagPets": "Pets",
"tagCooking": "Cooking"
},
"slides": {
"problemTitle": "Stop forgetting the little things",
"problemBody": "Donetick remembers the last time it happened, and when it's due next. So nobody else has to.",
"captureTitle": "Add it before you forget it",
"captureBody": "Type it, say it out loud, or snap a photo. Donetick fills in the due date, priority, labels, and who's on it.",
"scheduleTitle": "It comes back exactly when it should",
"scheduleBody": "Every week, every 3 months, or a month after you actually did it. Finishing late doesn't throw the rest of the year off.",
"circleTitle": "Everyone gets a turn, No more arguing",
"circleBody": "Rotate in the order you pick, or at random. Donetick remembers who went last, so turns stay fair.",
"remindersTitle": "A nudge right when it matters & Widgets!",
"remindersBody": "Get a reminder before something's due, or send a heads-up when someone forgets theirs. Today's list sits on your home screen too."
},
"carousel": {
"skip": "Skip",
"ariaLabel": "What you can do with Donetick",
"goToStep": "Go to step {{number}}",
"slidePosition": "{{index}} of {{total}}",
"turnOnReminders": "Turn on reminders",
"notNow": "Not now",
"next": "Next"
},
"heardAbout": {
"title": "Where'd you hear about Donetick?",
"subtitle": "Helps us know what's working. Totally optional.",
"detailPlaceholder": "Tell us where",
"continue": "Continue",
"skip": "Skip",
"sources": {
"appStore": "App Store / Google Play",
"reddit": "Reddit or a forum",
"friend": "Friend or family",
"video": "YouTube or TikTok",
"search": "Search engine",
"other": "Something else"
}
},
"privacy": {
"title": "Help us improve Donetick",
"subtitle": "Your server, your data. Both of these are off from this point unless you turn them on.",
"crashReportsLabel": "Share crash reports",
"crashReportsDescription": "When Donetick crashes, send us the error and what led to it (device, app version, the screen you were on) no task content. It's how we catch bugs that never show up in our own testing and ship fixes faster.",
"analyticsLabel": "Share anonymous usage analytics",
"analyticsDescription": "Send anonymous usage events which features get used, which get ignored so we know what to build next instead of guessing. No task content, no personal data, nothing tied back to you."
}
},
"errorReport": {
"titleBug": "Report an issue",
"titleCrash": "Report this problem",
"subtitleBug": "Tell us what went wrong and well attach the technical details for you.",
"subtitleCrash": "A sentence about what you were doing turns this into something we can actually fix.",
"labelBug": "What went wrong?",
"labelCrash": "What were you doing?",
"placeholderBug": "e.g. Completing a chore from the list doesnt update the due date",
"placeholderCrash": "e.g. I tapped a chore in My Chores and the screen went blank",
"email": "Email",
"emailOptional": "(optional — only if you want a reply)",
"emailPlaceholder": "you@example.com",
"showWhatGetsSent": "Show what gets sent",
"hideWhatGetsSent": "Hide what gets sent",
"copyDiagnostics": "Copy diagnostics",
"collecting": "Collecting diagnostics…",
"privacyNote": "No chore names, notes or attachments are included.",
"send": "Send report",
"cancel": "Cancel",
"notNow": "Not now",
"sentTitle": "Report sent",
"sentBody": "Thanks! this goes straight to the people who can fix it.",
"reference": "Reference",
"copyReference": "Copy reference",
"done": "Done",
"githubTitle": "Finish on GitHub",
"githubBody": "Nothing has been sent. We've filled in an issue with your notes and the diagnostics — review it and post when you're happy with it.",
"openIssue": "Open pre-filled issue",
"copied": "Copied",
"copyInstead": "Copy details instead",
"close": "Close"
},
"circleSetup": {
"inviteTitle": "Bring your squad in",
"inviteSubtitle": "Everyone who joins your Circle sees the same chores, takes their own turn, and stays in sync automatically.",
"joinTitle": "Join a circle",
"joinSubtitle": "Enter the code you were given and we'll send a request to join — the circle owner just needs to approve it.",
"loadingCode": "Loading…",
"copyCodeAria": "Copy circle code",
"copyLinkInstead": "Copy invite link instead",
"continue": "Continue",
"joinInstead": "Join an existing circle instead",
"codePlaceholder": "Enter code",
"joinButton": "Join Circle",
"back": "Back",
"codeCopied": "Code copied to clipboard",
"linkCopied": "Link copied to clipboard",
"requestSentTitle": "Request Sent",
"requestSentBody": "Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.",
"alreadyMember": "You are already a member of this circle",
"joinFailed": "Failed to join circle"
},
"joinCircle": {
"title": "You're invited to join a circle",
"incompleteTitle": "Invite link is incomplete",
"incompleteSubtitle": "This invite link is missing a code. Ask the person who invited you to send a new link.",
"goToDonetick": "Go to Donetick",
"signedOutSubtitle": "Sign in or create a Donetick account to continue. We'll send your join request once you're signed in.",
"signIn": "Sign in",
"createAccount": "Create an account",
"sendingTitle": "Sending your request",
"sendingSubtitle": "Sending your request…",
"greetingSubtitle": "Hi {{name}}. Send a request to share this circle's chores with its members.",
"adminReviewNote": "A circle admin will review your request before you get access.",
"sendRequest": "Send join request",
"requestSentTitle": "Request sent",
"requestSentBody": "Your request has been sent. A circle admin will need to approve it before you can access the circle and its chores. We'll notify you when it's approved.",
"gotIt": "Got it",
"alreadyMember": "You are already a member of this circle",
"joinFailed": "Failed to join circle",
"requestError": "Could not send your join request. Please try again."
}
}

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

@@ -21,6 +21,20 @@
"onTime": "On Time",
"onTimeLabel": "On time"
},
"card": {
"wasDue": "Was due {{date}}",
"daysBeforeDue_one": "{{count}}d before due date",
"daysBeforeDue_other": "{{count}}d before due date",
"daysAfterDue_one": "{{count}}d after due date",
"daysAfterDue_other": "{{count}}d after due date",
"hoursBeforeDue_one": "{{count}}h before due date",
"hoursBeforeDue_other": "{{count}}h before due date",
"hoursAfterDue_one": "{{count}}h after due date",
"hoursAfterDue_other": "{{count}}h after due date",
"assignedTo": "Assigned to {{name}}",
"points_one": "★ {{count}} pt",
"points_other": "★ {{count}} pts"
},
"detail": {
"title": "Activity Detail",
"openTask": "Open Task",
@@ -76,7 +90,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 +101,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

@@ -3,6 +3,32 @@
"chooseIcon": "Choose Project Icon",
"availableIcons": "Available Icons"
},
"icons": {
"FolderOpen": "Folder",
"Work": "Work",
"Home": "Home",
"School": "School",
"BusinessCenter": "Business",
"Code": "Code",
"Build": "Build",
"Palette": "Design",
"SportsSoccer": "Sports",
"FitnessCenter": "Fitness",
"ShoppingCart": "Shopping",
"Restaurant": "Food",
"Flight": "Travel",
"Book": "Study",
"MusicNote": "Music",
"PhotoCamera": "Photo",
"Games": "Games",
"Science": "Science",
"AccountBalance": "Finance",
"LocalHospital": "Health",
"DirectionsCar": "Auto",
"Pets": "Pets",
"Garden": "Garden",
"Computer": "Tech"
},
"defaultChip": "Default",
"tasks_one": "{{count}} task",
"tasks_other": "{{count}} tasks",
@@ -12,6 +38,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 +54,17 @@
"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...",
"nameLabel": "Project Name",
"descriptionLabel": "Description",
"iconLabel": "Project Icon",
"selectIcon": "Select Icon",
"colorLabel": "Project Color"
}
}

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."
}
}
},
@@ -168,7 +168,8 @@
"joinCircle": "Join Circle",
"joinedPending": "Joined circle successfully, wait for the circle owner to accept your request.",
"alreadyMember": "You are already a member of this circle",
"joinFailed": "Failed to join circle"
"joinFailed": "Failed to join circle",
"copyLink": "Copy Link"
},
"accountSettings": {
"title": "Account Settings",
@@ -352,7 +353,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",
@@ -422,7 +424,8 @@
"name": "Weekly Goals",
"description": "Shows weekly progress and family completion stats"
}
}
},
"detailedDescription": "Customize the layout and visibility of cards in the sidepanel. the section only available on large screen devices such as tablets and desktops.."
},
"theme": {
"title": "Theme Preferences",
@@ -456,7 +459,8 @@
"ymd": "YYYY-MM-DD (ISO)",
"long": "Long format (e.g., January 1, 2024)",
"short": "Short format (e.g., Jan 1, 2024)"
}
},
"descriptionLong": "Customize language, date format, and regional preferences for your account. These settings will apply throughout the application."
},
"advanced": {
"title": "Advanced Settings",
@@ -533,7 +537,13 @@
},
"passwordChange": {
"minLength": "Password must be at least 8 characters",
"maxLength": "Password must be less than 64 characters"
"maxLength": "Password must be less than 64 characters",
"description": "Choose a password between 8 and 64 characters.",
"mismatch": "Passwords do not match",
"newPassword": "New password",
"newPasswordPlaceholder": "Enter password",
"confirmPassword": "Confirm password",
"confirmPasswordPlaceholder": "Repeat password"
},
"payment": {
"cancelledTitle": "Payment has been cancelled",
@@ -565,7 +575,12 @@
"includeAssets": "Include uploaded files and assets",
"warningLabel": "Warning:",
"restoreWarning": "Restoring a backup will replace all your current data. This action cannot be undone.",
"restoreKeyPlaceholder": "Enter the encryption key used for this backup"
"restoreKeyPlaceholder": "Enter the encryption key used for this backup",
"title": "Backup & Restore",
"createTab": "Create Backup",
"restoreTab": "Restore Backup",
"creating": "Creating backup...",
"restoring": "Restoring backup..."
},
"deletion": {
"passwordRequired": "Please enter your password to continue",
@@ -575,6 +590,50 @@
"transferIntro": "You own circles that require ownership transfer before deletion. Please select new owners:",
"selectOwner": "Select new owner",
"confirmPrompt": "Please enter your password and type DELETE to confirm",
"typeDelete": "DELETE"
"typeDelete": "DELETE",
"warningLead": "This action cannot be undone.",
"warningIntro": "Deleting your account will permanently remove:",
"removesProfile": "Your user profile and authentication data",
"removesChores": "All your chores, chore history, and time tracking sessions",
"removesTokens": "API tokens, MFA sessions, and password reset tokens",
"removesStorage": "Storage files and usage data",
"removesPoints": "Points history and notifications",
"removesCircles": "Circle memberships and relationships",
"passwordToContinue": "Enter your password to continue",
"circleLabel": "Circle: {{name}}",
"newOwner": "New Owner",
"logoutNotice": "On successful deletion, you will be logged out and redirected to the login page.",
"passwordLabel": "Password",
"typeToConfirm": "Type \"{{word}}\" to confirm"
},
"realtime": {
"titleSse": "Real-time Updates (SSE)",
"subtitleSse": "Get instant notifications via Server-Sent Events"
},
"nativeCancel": {
"title": "Cancel Subscription",
"dismiss": "Dismiss",
"cancelViaStore": "I'll cancel from my app store",
"cancelDesktop": "Cancel desktop subscription",
"intro": "To cancel your subscription, please follow the instructions for your platform (you should cancel through the same platform you used to subscribe).",
"iosHeading": "For iOS (iPhone/iPad):",
"iosStep1": "1. Open the <b>Settings</b> app on your device",
"iosStep2": "2. Tap your name at the top of the screen",
"iosStep3": "3. Tap <b>Subscriptions</b>",
"iosStep4": "4. Find and tap <b>Donetick</b>",
"iosStep5": "5. Tap <b>Cancel Subscription</b>",
"iosNote": "<b>Note:</b> If you subscribed through iOS and are using the web/desktop version, you must cancel through iOS Settings as described above.",
"androidHeading": "For Android:",
"androidStep1": "1. Open the <b>Google Play Store</b> app",
"androidStep2": "2. Tap the profile icon in the top right",
"androidStep3": "3. Tap <b>Payments & subscriptions</b>",
"androidStep4": "4. Tap <b>Subscriptions</b>",
"androidStep5": "5. Find and tap <b>Donetick</b>",
"androidStep6": "6. Tap <b>Cancel subscription</b>",
"androidNote": "<b>Note:</b> If you subscribed through Google Play and are using the web/desktop version, you must cancel through Google Play as described above.",
"webHeading": "For Web/Desktop Subscriptions:",
"webBody": "If you originally subscribed through our website or desktop app, you can cancel your subscription by going to the Account Settings section on our website. using a web browser",
"webNote": "<b>Important:</b> You must cancel your subscription through the same platform where you originally subscribed. If you subscribed through the iOS App Store or Google Play Store (even if you're now using the web/desktop version), you must cancel through that original platform using the instructions above.",
"footer": "Your subscription will remain active until the end of your current billing period."
}
}

View File

@@ -14,5 +14,47 @@
"visualization": "Data Visualization",
"changeHistory": "Change History",
"updated": "Updated"
},
"view": {
"title": "Things",
"description": "Things are custom fields that can be attached to tasks to capture additional information. They can be of type text, number, or boolean. You can associate things with tasks and have the task due once condition is met",
"searchPlaceholder": "Search things",
"sortName": "Name",
"sortType": "Type",
"sortState": "State",
"sortUpdated": "Last updated",
"filterTitle": "Type",
"filterAll": "All types",
"emptyTitle": "No things yet",
"emptyDescription": "A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.",
"emptyAction": "Create a thing",
"noResultsTitle": "No things match",
"noResultsSearch": "No thing matches \"{{term}}\".",
"noResultsFilter": "No thing matches the current filter.",
"clearSearch": "Clear search",
"showAll": "Show all things",
"toggle": "Toggle"
},
"types": {
"text": "Text",
"number": "Number",
"boolean": "Boolean"
},
"notify": {
"savedTitle": "Saved",
"savedMessage": "Thing saved successfully",
"saveFailTitle": "Unable to save thing",
"queuedMessage": "You are offline and the request has been queued",
"saveFailMessage": "An error occurred while saving the thing",
"deleteTitle": "Delete Things",
"deleteMessage": "Are you sure you want to delete this Thing?",
"deleteBlockedTitle": "Unable to Delete Thing",
"deleteBlockedMessage": "Unable to delete thing with associated tasks",
"deleteFailTitle": "Unable to delete thing",
"deleteFailMessage": "An error occurred while deleting the thing",
"updatedTitle": "Updated",
"updatedMessage": "Thing state updated successfully",
"updateFailTitle": "Unable to update thing state",
"updateFailMessage": "An error occurred while updating the thing state"
}
}

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,12 @@
"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",
"emptySessionsTitle": "No work sessions yet",
"emptySessionsDescription": "Start the timer on this task and each session lands here.",
"percentActive": "{{percent}}% active",
"activityTimeline": "Activity Timeline",
"liveSession": "Live Session",
"live": "Live"
}

View File

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

View File

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

137
scripts/i18n-audit.mjs Normal file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* Fails if any t() / <Trans> key in src/ has no entry in public/locales/en/.
*
* Why this is a script and not an ESLint rule: the namespace for a bare
* t('foo') comes from the useTranslation('chores') call at the top of the
* component, so resolving a key needs whole-file context that a per-node lint
* rule does not have. Editor i18n plugins get this wrong constantly — they
* assume the default namespace and flag half the codebase.
*
* Usage: node scripts/i18n-audit.mjs [--orphans]
* --orphans also list en/*.json keys that nothing in src/ references
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { basename, join } from 'node:path'
const LOCALE_DIR = 'public/locales/en'
const SRC_DIR = 'src'
const DEFAULT_NS = 'common'
const bundles = Object.fromEntries(
readdirSync(LOCALE_DIR)
.filter(f => f.endsWith('.json'))
.map(f => [
basename(f, '.json'),
JSON.parse(readFileSync(join(LOCALE_DIR, f), 'utf8')),
]),
)
const walk = dir =>
readdirSync(dir).flatMap(entry => {
const full = join(dir, entry)
return statSync(full).isDirectory()
? walk(full)
: /\.jsx?$/.test(full)
? [full]
: []
})
const lookup = (ns, key) =>
key.split('.').reduce((cur, part) => {
if (cur === null || typeof cur !== 'object' || !(part in cur)) return null
return cur[part]
}, bundles[ns] ?? null)
/**
* i18next appends _one/_other to plural keys, so `card.points` is present in
* the bundle as `card.points_one` + `card.points_other`. Treat a leaf whose
* siblings carry a plural suffix as resolved.
*/
const exists = (ns, key) => {
if (lookup(ns, key) !== null) return true
const dot = key.lastIndexOf('.')
const parent =
dot === -1 ? (bundles[ns] ?? null) : lookup(ns, key.slice(0, dot))
const leaf = key.slice(dot + 1)
return (
parent !== null &&
typeof parent === 'object' &&
Object.keys(parent).some(k => k.startsWith(`${leaf}_`))
)
}
const collectKeys = src => {
const found = new Set()
const push = re => {
for (const m of src.matchAll(re)) found.add(m[1])
}
// t('key') / t("key") / t(`key`)
push(/\bt\(\s*'([A-Za-z][\w.:-]*)'/g)
push(/\bt\(\s*"([A-Za-z][\w.:-]*)"/g)
push(/\bt\(\s*`([A-Za-z][\w.:-]*)`/g)
// t(`prefix.${expr}`) — the interpolated leaf is unknowable, but the
// prefix must at least resolve to an object
push(/\bt\(\s*`([A-Za-z][\w.:-]*)\.\$\{/g)
return found
}
const failures = []
for (const file of walk(SRC_DIR).sort()) {
const src = readFileSync(file, 'utf8')
const nsMatch = src.match(/useTranslation\(\s*(?:'([^']+)'|\[\s*'([^']+)')/)
const hasTrans = src.includes('i18nKey')
if (!nsMatch && !hasTrans) continue
const fallbackNs = nsMatch ? (nsMatch[1] ?? nsMatch[2]) : DEFAULT_NS
const check = (raw, kind, nsOverride) => {
const [ns, key] = raw.includes(':')
? raw.split(/:(.+)/)
: [nsOverride ?? fallbackNs, raw]
if (!exists(ns, key)) failures.push(`${file} ${kind} ${ns}:${key}`)
}
for (const raw of collectKeys(src)) check(raw, 't()')
// <Trans i18nKey='x' ns='y'> and thin wrappers that forward i18nKey
const transNs = src.match(/\bns='([^']+)'/)?.[1]
for (const m of src.matchAll(/i18nKey=(?:'([^']+)'|\{'([^']+)'\})/g)) {
check(m[1] ?? m[2], 'Trans', transNs)
}
}
if (process.argv.includes('--orphans')) {
const allSrc = walk(SRC_DIR)
.map(f => readFileSync(f, 'utf8'))
.join('\n')
const flatten = (obj, prefix = '') =>
Object.entries(obj).flatMap(([k, v]) =>
v !== null && typeof v === 'object'
? flatten(v, `${prefix}${k}.`)
: [`${prefix}${k}`],
)
const orphans = []
for (const [ns, bundle] of Object.entries(bundles)) {
for (const key of flatten(bundle)) {
const leaf = key.replace(/_(one|other|zero|two|few|many)$/, '')
if (!allSrc.includes(leaf) && !allSrc.includes(leaf.split('.').pop())) {
orphans.push(`${ns}:${leaf}`)
}
}
}
if (orphans.length) {
console.log(`\nPossibly unused (${new Set(orphans).size}):`)
console.log([...new Set(orphans)].sort().join('\n'))
}
}
const unique = [...new Set(failures)].sort()
if (unique.length) {
console.error(`i18n audit: ${unique.length} unresolved key(s)\n`)
console.error(unique.join('\n'))
process.exit(1)
}
console.log('i18n audit: all keys resolve')

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('notificationTemplate.onDueDate')
}
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
const unitName = t(`notificationTemplate.unitName.${unit}`)
const absValue = Math.abs(numericValue)
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
return numericValue < 0
? t('notificationTemplate.beforeDue', { count: absValue, unit: unitName })
: t('notificationTemplate.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('notificationTemplate.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('notificationTemplate.errOneDue'))
return
}
newNotification = { value: 0, unit: 'm' }
} else {
newNotification = getSmartSuggestion(type)
if (!newNotification) {
setError(`All common ${type} times are already configured.`)
setError(t('notificationTemplate.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(`notificationTemplate.timing.${opt.value}`)}
</Option>
))}
</Select>
@@ -638,7 +641,7 @@ const NotificationTemplate = ({
>
{timeUnits.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
{t(`notificationTemplate.unitShort.${opt.value}`)}
</Option>
))}
</Select>
@@ -688,7 +691,7 @@ const NotificationTemplate = ({
},
}}
>
Reminder
{t('notificationTemplate.reminder')}
</Button>
<Button
onClick={() => addSmartNotification('due')}
@@ -710,7 +713,7 @@ const NotificationTemplate = ({
},
}}
>
Due Alert
{t('notificationTemplate.dueAlert')}
</Button>
<Button
onClick={() => addSmartNotification('followup')}
@@ -729,7 +732,7 @@ const NotificationTemplate = ({
},
}}
>
Follow-up
{t('notificationTemplate.followUp')}
</Button>
</Box>
{showSaveDefault && (
@@ -762,7 +765,7 @@ const NotificationTemplate = ({
setShowSaveDefault(false)
}}
>
Remember for Future Tasks
{t('notificationTemplate.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,15 +78,15 @@ 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
{t('common.plusFeature')}
</Chip>
)}
</Typography>
<Typography level='body-sm' color='neutral'>
Get instant notifications via Server-Sent Events
{t('realtime.subtitleSse')}
</Typography>
</Box>
{isSSEEnabled() && isPlusAccount(userProfile) && (

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)
@@ -60,20 +63,20 @@ const UserProfileAvatar = () => {
const isPlusUser = isPlusAccount(userProfile)
const getSubscriptionStatus = () => {
if (!userProfile) return 'Free'
if (!userProfile) return t('userMenu.planFree')
if (userProfile.subscription === 'active') {
return 'Plus'
return t('userMenu.planPlus')
}
if (
userProfile.subscription === 'cancelled' &&
moment().isBefore(userProfile.expiration)
) {
return 'Plus (expires soon)'
return t('userMenu.planPlusExpiring')
}
return 'Free'
return t('userMenu.planFree')
}
const handleLogout = () => {
@@ -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={{
@@ -265,7 +270,7 @@ const UserProfileAvatar = () => {
fontWeight: 500,
}}
>
Impersonating
{t('userMenu.impersonating')}
</Typography>
</Box>
)}
@@ -291,13 +296,15 @@ const UserProfileAvatar = () => {
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
{isImpersonating ? 'Switch User' : 'Impersonate User'}
{isImpersonating
? t('userMenu.switchUser')
: t('userMenu.impersonateUser')}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Act as another user
{t('userMenu.actAsAnother')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -319,13 +326,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 +356,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 +381,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 +408,7 @@ const UserProfileAvatar = () => {
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Side Panel Settings
{t('userMenu.sidePanelSettings')}
</Typography>
<Typography
level='body-xs'
@@ -427,13 +434,15 @@ const UserProfileAvatar = () => {
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
{isDarkMode ? 'Switch to Light' : 'Switch to Dark'}
{isDarkMode
? t('userMenu.switchToLight')
: t('userMenu.switchToDark')}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Toggle theme appearance
{t('userMenu.toggleThemeAppearance')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -460,9 +469,11 @@ const UserProfileAvatar = () => {
fontWeight: 500,
}}
>
Upgrade to Plus
{t('userMenu.upgradeToPlus')}
</Typography>
<Typography level='body-xs'>
{t('userMenu.unlockPremium')}
</Typography>
<Typography level='body-xs'>Unlock premium features</Typography>
</ListItemContent>
</MenuItem>
)}
@@ -511,7 +522,7 @@ const UserProfileAvatar = () => {
level='body-sm'
sx={{ fontWeight: 500, color: 'var(--joy-palette-danger-500)' }}
>
Logout
{t('logout')}
</Typography>
</ListItemContent>
</MenuItem>

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

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