Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b3a0e5a8 | ||
|
|
a92eeaf149 | ||
|
|
86989362fe | ||
|
|
cb81749dbf | ||
|
|
0bac419c3a | ||
|
|
1fa4748168 | ||
|
|
c1318a2cc4 | ||
|
|
cc2e656e14 | ||
|
|
77f87daeba | ||
|
|
64479f0858 | ||
|
|
4c235c7d00 | ||
|
|
c0d2d5a9ec | ||
|
|
dae647a4be | ||
|
|
f27bb79a07 | ||
|
|
631d273b02 | ||
|
|
d4b21f8f4e | ||
|
|
a407606f1c | ||
|
|
4f21242e39 | ||
|
|
2a4f085e18 | ||
|
|
253514a6e9 | ||
|
|
e18c829ae7 | ||
|
|
9329eaf3a9 | ||
|
|
292597e7c6 | ||
|
|
e377fb5b06 | ||
|
|
84f86032c9 | ||
|
|
834db6b39d | ||
|
|
97453803d6 | ||
|
|
1c99bd1886 | ||
|
|
5359ae193b | ||
|
|
325d5e6df7 | ||
|
|
79d92ca8c9 | ||
|
|
2480049fd1 | ||
|
|
5c71e4ce58 | ||
|
|
77834aa3c0 | ||
|
|
76fb3500ec | ||
|
|
2d5dd81371 | ||
|
|
c6f7ce48d8 | ||
|
|
64aca507af | ||
|
|
d813971809 | ||
|
|
7729917611 | ||
|
|
96ba672b09 | ||
|
|
7d36090e46 | ||
|
|
f43369c960 | ||
|
|
64fa636413 | ||
|
|
c1f6431174 | ||
|
|
537ab0568b | ||
|
|
b095f26d61 | ||
|
|
2d776e973a | ||
|
|
ddb726ad1f | ||
|
|
67593ec284 | ||
|
|
6bc5982294 | ||
|
|
1dd01e0eca | ||
|
|
ede1a11099 | ||
|
|
6e97c70570 | ||
|
|
dfa07aa5d1 |
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,2 +1,13 @@
|
||||
LICENSE.md
|
||||
dist/
|
||||
build/
|
||||
vendor/
|
||||
android/
|
||||
ios/
|
||||
resources/
|
||||
.bundle/
|
||||
.agents/
|
||||
.impeccable/
|
||||
package-lock.json
|
||||
Gemfile.lock
|
||||
skills-lock.json
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 67
|
||||
versionName "1.2.46"
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"sessions":{}}
|
||||
{ "version": 1, "sessions": {} }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
"capacitor": {}
|
||||
},
|
||||
"type": "custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,12 +462,12 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 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.46;
|
||||
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 = 67;
|
||||
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.46;
|
||||
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 = 67;
|
||||
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.46;
|
||||
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 = 67;
|
||||
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.46;
|
||||
MARKETING_VERSION = 1.2.49;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -11,4 +11,4 @@
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,4 @@
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
630
package-lock.json
generated
630
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.46",
|
||||
"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",
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 we’ll 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 doesn’t 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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -492,4 +492,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,4 +72,4 @@
|
||||
"delete": "删除会话"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
137
scripts/i18n-audit.mjs
Normal file
137
scripts/i18n-audit.mjs
Normal 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')
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } } }
|
||||
|
||||
256
src/components/common/SortAndFilterMenu.jsx
Normal file
256
src/components/common/SortAndFilterMenu.jsx
Normal 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
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { initWidgetSync } from '../service/WidgetService'
|
||||
|
||||
const QueryContext = ({ children }) => {
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import { useSSE } from '../hooks/useSSE'
|
||||
|
||||
export const SSEContext = createContext({
|
||||
|
||||
@@ -31,6 +31,32 @@ const primaryPalette = getPalette(primaryColor)
|
||||
const CONTROL_RADIUS = '12px'
|
||||
const ICON_BUTTON_RADIUS = '10px'
|
||||
|
||||
// Select and Autocomplete both render their popup through @mui/base's Popper,
|
||||
// which defaults to popper.js's `absolute` strategy — the popup is laid out in
|
||||
// *document* coordinates. That is fine on desktop, but inside a mobile bottom
|
||||
// sheet (viewport-pinned, body scroll locked by Modal) it puts the list below
|
||||
// the fold or lets an overflow:hidden ancestor clip it, so it reads as trapped
|
||||
// inside the modal. `fixed` positions it in viewport space instead, which no
|
||||
// ancestor can clip.
|
||||
//
|
||||
// Note that slotProps coming from defaultProps are not deep merged: a call site
|
||||
// passing its own slotProps.listbox replaces this wholesale.
|
||||
const POPUP_LISTBOX = {
|
||||
popperOptions: { strategy: 'fixed' },
|
||||
modifiers: [
|
||||
{ name: 'flip', options: { padding: 8 } },
|
||||
// altAxis with tether off lets the popup shift fully back into view rather
|
||||
// than staying glued to an anchor that has no room around it.
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: { padding: 8, altAxis: true, tether: false },
|
||||
},
|
||||
],
|
||||
// Joy hardcodes 44vh, and vh resolves to the *large* viewport on mobile —
|
||||
// taller than what is actually on screen while the browser chrome is up.
|
||||
sx: { maxHeight: 'min(44dvh, 320px)' },
|
||||
}
|
||||
|
||||
const themeConfig = {
|
||||
radius: {
|
||||
xs: '6px',
|
||||
@@ -163,6 +189,10 @@ const themeConfig = {
|
||||
theme.direction === 'rtl' ? buttonGroupRtlGeometry(ownerState) : {},
|
||||
},
|
||||
},
|
||||
JoySelect: { defaultProps: { slotProps: { listbox: POPUP_LISTBOX } } },
|
||||
JoyAutocomplete: {
|
||||
defaultProps: { slotProps: { listbox: POPUP_LISTBOX } },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
|
||||
class NetworkManager {
|
||||
|
||||
@@ -36,4 +36,4 @@ const useAcknowledgmentModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export default useAcknowledgmentModal
|
||||
export default useAcknowledgmentModal
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -40,4 +40,4 @@ const useConfirmationModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfirmationModal
|
||||
export default useConfirmationModal
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { createElement } from 'react'
|
||||
|
||||
import AppModal from '../components/common/AppModal'
|
||||
|
||||
const MobileAppModal = props =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { SSEContext } from '../contexts/SSEContext'
|
||||
|
||||
export const useSSEContext = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
|
||||
import { GetThingHistory } from '../utils/Fetcher'
|
||||
|
||||
export const useThingHistory = (thingId, limit = 10) => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user