diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38671a92..d8caebe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,6 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Generate Convex code (non-blocking) - continue-on-error: true - run: bunx convex codegen - - name: Lint run: bun run lint diff --git a/AGENTS.md b/AGENTS.md index a26a2968..73c49e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,9 @@ Local workspace additions: - Prefer the `jcodemunch` MCP server for repository exploration when it is available. - Use `jcodemunch` for symbol search, file outlines, repo outlines, and targeted code retrieval before falling back to broad file reads. - Fall back to `rg`, `sed`, and direct file reads when `jcodemunch` is unavailable or when raw file context is clearly more appropriate. +- For UI architecture work, prefer deletion over adaptation when an abstraction is dormant, duplicated, or only partially wired. +- Maintain one primary styling path. Remove inactive Tailwind/NativeWind plumbing rather than preserving “optional” styling systems. +- Maintain one runtime theme source. Do not keep fake appearance modes or duplicate token layers alive for compatibility alone. +- Prefer a small primitive layer (`text`, `icon`, `pressable`, `surface`, `field`, `button`, `list-row`) and compose feature widgets from it. +- Reduce role-based duplication by extracting shared presenters and thin role-specific data containers when possible. +- Remove pass-through screen/layout wrappers unless they enforce a real invariant shared across routes. diff --git a/README.md b/README.md index 720a0634..1fe183fa 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,12 @@ bun install ## Run +- Full dev stack with Bun: + - `bun run dev` - Start Expo: - `bun run start` +- Start Convex with on-the-fly `_generated` updates: + - `bun run convex:dev` - Android (native Windows emulator flow): - `bun run android` - Android doctor: @@ -24,4 +28,5 @@ bun install ## Notes - Android workflow is Windows-first (no WSL requirement). +- Convex updates `convex/_generated` automatically while `bun run convex:dev` is running. Keep `_generated` committed; `bunx convex codegen` is only for explicit regeneration and CI validation. - See `docs/android-windows-setup.md` for details. diff --git a/babel.config.js b/babel.config.js index dd096a0c..ed1fbc56 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,7 @@ module.exports = (api) => { api.cache(true); return { - presets: [["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel"], + presets: ["babel-preset-expo"], plugins: ["react-native-reanimated/plugin"], }; }; diff --git a/bun.lock b/bun.lock index 308e665f..23bb3a1d 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,6 @@ "convex": "^1.32.0", "expo": "~55.0.5", "expo-auth-session": "~55.0.7", - "expo-blur": "~55.0.8", "expo-build-properties": "~55.0.9", "expo-calendar": "~55.0.9", "expo-constants": "~55.0.7", @@ -37,7 +36,6 @@ "expo-linking": "~55.0.7", "expo-localization": "~55.0.8", "expo-location": "~55.1.2", - "expo-navigation-bar": "~55.0.8", "expo-notifications": "~55.0.11", "expo-router": "~55.0.4", "expo-secure-store": "~55.0.8", @@ -49,7 +47,6 @@ "expo-web-browser": "~55.0.9", "geojson": "^0.5.0", "i18next": "^25.8.14", - "nativewind": "4.2.2", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^16.5.5", @@ -71,14 +68,11 @@ "@types/react": "~19.2.14", "babel-preset-expo": "~55.0.10", "knip": "^5.85.0", - "tailwindcss": "3.4.19", "typescript": "~5.9.3", }, }, }, "packages": { - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@auth/core": ["@auth/core@0.37.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "@types/cookie": "0.6.0", "cookie": "0.7.1", "jose": "^5.9.3", "oauth4webapi": "^3.0.0", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -719,20 +713,16 @@ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "appdirsjs": ["appdirsjs@1.2.7", "", {}, "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw=="], - "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "arg": ["arg@4.1.0", "", {}, "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], - "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], "astral-regex": ["astral-regex@1.0.0", "", {}, "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="], @@ -777,8 +767,6 @@ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -815,14 +803,10 @@ "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="], @@ -853,8 +837,6 @@ "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], - "comment-json": ["comment-json@4.5.1", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg=="], - "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], @@ -873,8 +855,6 @@ "core-js-compat": ["core-js-compat@3.48.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q=="], - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], @@ -889,8 +869,6 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], @@ -917,10 +895,6 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], - - "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], - "dnssd-advertise": ["dnssd-advertise@1.1.3", "", {}, "sha512-XENsHi3MBzWOCAXif3yZvU1Ah0l+nhJj1sjWL6TnOAYKvGiFhbTx32xHN7+wLMLUOCj7Nr0evADWG4R8JtqCDA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -983,8 +957,6 @@ "expo-auth-session": ["expo-auth-session@55.0.7", "", { "dependencies": { "expo-application": "~55.0.8", "expo-constants": "~55.0.7", "expo-crypto": "~55.0.9", "expo-linking": "~55.0.7", "expo-web-browser": "~55.0.9", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-SWFudyF7K6924cSgPFrtK637aXj6HSs+DO6cP0q5xfwlcLaekkcoOHIrhpy8qaUIpxf7w01Dsk5fwu5RLse4eg=="], - "expo-blur": ["expo-blur@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-hmYhQiYFMTpHNzFaCF63jTABgnsozB5+wSEV8rCNBfNEHTdRVNFPkF+TZpbIqJpkhHfGLsKptRmR/wyZmAqREA=="], - "expo-build-properties": ["expo-build-properties@55.0.9", "", { "dependencies": { "@expo/schema-utils": "^55.0.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-p0rNHW/6ghKsvjlUn2DQfbLYuTB6ba+15SeTPOz5BYbyU1F/0F/YyxBtHdmWitqgDPn6VgXQeKhiNC1fMwYDpg=="], "expo-calendar": ["expo-calendar@55.0.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ZsNcz/Zc9e3gGQ6oT9e+He98WiIt105DpR1hxPpEw26XuZsFCRjBHbaHf0bULWnUt3aNoaswNl9BlNo+v+AFbw=="], @@ -1037,8 +1009,6 @@ "expo-modules-core": ["expo-modules-core@55.0.14", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-eAerOnnhbZitUAKbY7B61kIudiabAz/m/oMGINms2+GeY1DRhdvrm5aAkhkHHmykPrg58PPryXtmF14YAYWViw=="], - "expo-navigation-bar": ["expo-navigation-bar@55.0.8", "", { "dependencies": { "debug": "^4.3.2", "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-3Wa7m1zXuuH7FjwvwBR44ZRW+Ixn/GxS2anUCiWXFdPXjKLIYtAtr37vJ2c3KgFqNyhRlg1sgFawk4A8V5swhg=="], - "expo-notifications": ["expo-notifications@55.0.11", "", { "dependencies": { "@expo/image-utils": "^0.8.12", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~55.0.8", "expo-constants": "~55.0.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-nx9OIx0qJh6HWKW2Jq8uF365q56eDFcAnDMMKOpA3eEFhBy6IzTJNrq9AGdOoylWI4OeVW0Z6wxEXPZOan+9VA=="], "expo-router": ["expo-router@55.0.4", "", { "dependencies": { "@expo/metro-runtime": "^55.0.6", "@expo/schema-utils": "^55.0.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.10.1", "@react-navigation/native": "^7.1.28", "@react-navigation/native-stack": "^7.10.1", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^55.0.7", "expo-image": "^55.0.6", "expo-server": "^55.0.6", "expo-symbols": "^55.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.2.1", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@expo/log-box": "55.0.7", "@react-navigation/drawer": "^7.7.2", "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^55.0.7", "expo-linking": "^55.0.7", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wLKxc9l3IaE96UJFvwXKi2YYYjYK/VUttwAwcnljaUA2dLgDruNGmjsBS9A+g3aK3lt2/JJRu+cec7ZLJ9r6Wg=="], @@ -1089,8 +1059,6 @@ "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fetch-nodeshim": ["fetch-nodeshim@0.4.8", "", {}, "sha512-YW5vG33rabBq6JpYosLNoXoaMN69/WH26MeeX2hkDVjN6UlvRGq3Wkazl9H0kisH95aMu/HtHL64JUvv/+Nv/g=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -1137,7 +1105,7 @@ "glob": ["glob@13.0.4", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-KACie1EOs9BIOMtenFaxwmYODWA3/fTfGSUnLhMJpXRntu1g+uL/Xvub5f8SCTppvo9q62Qy4LeOoUiaL54G5A=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], @@ -1195,8 +1163,6 @@ "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -1311,8 +1277,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -1399,12 +1363,8 @@ "multitars": ["multitars@0.2.4", "", {}, "sha512-XgLbg1HHchFauMCQPRwMj6MSyDd5koPlTA1hM3rUFkeXzGpjU/I9fP3to7yrObE9jcN8ChIOQGrM0tV0kUZaKg=="], - "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "nativewind": ["nativewind@4.2.2", "", { "dependencies": { "comment-json": "^4.2.5", "debug": "^4.3.7", "react-native-css-interop": "0.2.2" }, "peerDependencies": { "tailwindcss": ">3.3.0" } }, "sha512-kUGbUamKUWdnAIjfBuhIrtDHFtMyL1pEE3AEbCuKeg656pHuB0KtJRk6Lrie/+8haj8hCSlwOleQFJLrE1sZgA=="], - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], "nocache": ["nocache@3.0.4", "", {}, "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw=="], @@ -1437,8 +1397,6 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1485,8 +1443,6 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], - "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], @@ -1497,16 +1453,6 @@ "postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], - - "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], - - "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - - "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], - - "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], "preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="], @@ -1555,8 +1501,6 @@ "react-native": ["react-native@0.83.2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.2", "@react-native/codegen": "0.83.2", "@react-native/community-cli-plugin": "0.83.2", "@react-native/gradle-plugin": "0.83.2", "@react-native/js-polyfills": "0.83.2", "@react-native/normalize-colors": "0.83.2", "@react-native/virtualized-lists": "0.83.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-ZDma3SLkRN2U2dg0/EZqxNBAx4of/oTnPjXAQi299VLq2gdnbZowGy9hzqv+O7sTA62g+lM1v+2FM5DUnJ/6hg=="], - "react-native-css-interop": ["react-native-css-interop@0.2.2", "", { "dependencies": { "@babel/helper-module-imports": "^7.22.15", "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", "debug": "^4.3.7", "lightningcss": "~1.27.0", "semver": "^7.6.3" }, "peerDependencies": { "react": ">=18", "react-native": "*", "react-native-reanimated": ">=3.6.2", "tailwindcss": "~3" } }, "sha512-2eUyl7RH1RT6TYbe5nm+d4HZ2Pr6Nmve158B57tb5W4Bo52Xzp+PFeWAdFnAr2HNB+r9b6qa8o3xH1YREVQU0g=="], - "react-native-gesture-handler": ["react-native-gesture-handler@2.30.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], @@ -1581,12 +1525,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -1725,8 +1665,6 @@ "styleq": ["styleq@0.1.3", "", {}, "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="], - "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], @@ -1737,22 +1675,14 @@ "swr": ["swr@2.3.4", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg=="], - "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], - "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], - - "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], - "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], @@ -1765,8 +1695,6 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -1885,6 +1813,8 @@ "@expo/cli/@expo/config-plugins": ["@expo/config-plugins@55.0.6", "", { "dependencies": { "@expo/config-types": "^55.0.5", "@expo/json-file": "~10.0.12", "@expo/plist": "^0.5.2", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A=="], + "@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/cli/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], @@ -1897,6 +1827,8 @@ "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@expo/fingerprint/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/prebuild-config/@expo/config-plugins": ["@expo/config-plugins@55.0.6", "", { "dependencies": { "@expo/config-types": "^55.0.5", "@expo/json-file": "~10.0.12", "@expo/plist": "^0.5.2", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A=="], @@ -1949,8 +1881,6 @@ "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "chrome-launcher/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], @@ -1973,10 +1903,6 @@ "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - "expo-updates/arg": ["arg@4.1.0", "", {}, "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg=="], - - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], "fbjs/ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], @@ -2039,8 +1965,6 @@ "react-native/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "react-native-css-interop/lightningcss": ["lightningcss@1.27.0", "", { "dependencies": { "detect-libc": "^1.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.27.0", "lightningcss-darwin-x64": "1.27.0", "lightningcss-freebsd-x64": "1.27.0", "lightningcss-linux-arm-gnueabihf": "1.27.0", "lightningcss-linux-arm64-gnu": "1.27.0", "lightningcss-linux-arm64-musl": "1.27.0", "lightningcss-linux-x64-gnu": "1.27.0", "lightningcss-linux-x64-musl": "1.27.0", "lightningcss-win32-arm64-msvc": "1.27.0", "lightningcss-win32-x64-msvc": "1.27.0" } }, "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ=="], - "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], @@ -2051,8 +1975,6 @@ "react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2069,10 +1991,6 @@ "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - - "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -2157,28 +2075,6 @@ "logkitty/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], - "react-native-css-interop/lightningcss/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], - - "react-native-css-interop/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ=="], - - "react-native-css-interop/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg=="], - - "react-native-css-interop/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA=="], - - "react-native-css-interop/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA=="], - - "react-native-css-interop/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A=="], - - "react-native-css-interop/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg=="], - - "react-native-css-interop/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A=="], - - "react-native-css-interop/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA=="], - - "react-native-css-interop/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ=="], - - "react-native-css-interop/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw=="], - "react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index db3ff1b6..97de2f21 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,15 +9,23 @@ */ import type * as auth from "../auth.js"; +import type * as calendar from "../calendar.js"; +import type * as calendarNode from "../calendarNode.js"; import type * as constants from "../constants.js"; import type * as didit from "../didit.js"; import type * as home from "../home.js"; +import type * as homeRead from "../homeRead.js"; import type * as http from "../http.js"; import type * as inbox from "../inbox.js"; import type * as instructorZones from "../instructorZones.js"; +import type * as integrations_rapyd_client from "../integrations/rapyd/client.js"; +import type * as integrations_rapyd_config from "../integrations/rapyd/config.js"; +import type * as integrations_rapyd_payloads from "../integrations/rapyd/payloads.js"; import type * as invoicing from "../invoicing.js"; import type * as jobs from "../jobs.js"; import type * as lib_auth from "../lib/auth.js"; +import type * as lib_calendarCrypto from "../lib/calendarCrypto.js"; +import type * as lib_calendarShared from "../lib/calendarShared.js"; import type * as lib_domainValidation from "../lib/domainValidation.js"; import type * as lib_instructorCoverage from "../lib/instructorCoverage.js"; import type * as lib_instructorEligibility from "../lib/instructorEligibility.js"; @@ -27,6 +35,7 @@ import type * as notifications from "../notifications.js"; import type * as notificationsCore from "../notificationsCore.js"; import type * as onboarding from "../onboarding.js"; import type * as payments from "../payments.js"; +import type * as paymentsRead from "../paymentsRead.js"; import type * as payouts from "../payouts.js"; import type * as rapyd from "../rapyd.js"; import type * as rapydReturnBridge from "../rapydReturnBridge.js"; @@ -34,8 +43,8 @@ import type * as resendMagicLink from "../resendMagicLink.js"; import type * as resendOtp from "../resendOtp.js"; import type * as userPushNotifications from "../userPushNotifications.js"; import type * as users from "../users.js"; -import type * as webhooks from "../webhooks.js"; import type * as webhookSecurity from "../webhookSecurity.js"; +import type * as webhooks from "../webhooks.js"; import type { ApiFromModules, @@ -45,15 +54,23 @@ import type { declare const fullApi: ApiFromModules<{ auth: typeof auth; + calendar: typeof calendar; + calendarNode: typeof calendarNode; constants: typeof constants; didit: typeof didit; home: typeof home; + homeRead: typeof homeRead; http: typeof http; inbox: typeof inbox; instructorZones: typeof instructorZones; + "integrations/rapyd/client": typeof integrations_rapyd_client; + "integrations/rapyd/config": typeof integrations_rapyd_config; + "integrations/rapyd/payloads": typeof integrations_rapyd_payloads; invoicing: typeof invoicing; jobs: typeof jobs; "lib/auth": typeof lib_auth; + "lib/calendarCrypto": typeof lib_calendarCrypto; + "lib/calendarShared": typeof lib_calendarShared; "lib/domainValidation": typeof lib_domainValidation; "lib/instructorCoverage": typeof lib_instructorCoverage; "lib/instructorEligibility": typeof lib_instructorEligibility; @@ -63,6 +80,7 @@ declare const fullApi: ApiFromModules<{ notificationsCore: typeof notificationsCore; onboarding: typeof onboarding; payments: typeof payments; + paymentsRead: typeof paymentsRead; payouts: typeof payouts; rapyd: typeof rapyd; rapydReturnBridge: typeof rapydReturnBridge; @@ -70,8 +88,8 @@ declare const fullApi: ApiFromModules<{ resendOtp: typeof resendOtp; userPushNotifications: typeof userPushNotifications; users: typeof users; - webhooks: typeof webhooks; webhookSecurity: typeof webhookSecurity; + webhooks: typeof webhooks; }>; /** diff --git a/convex/calendar.ts b/convex/calendar.ts index ae71d3bd..01952769 100644 --- a/convex/calendar.ts +++ b/convex/calendar.ts @@ -1,296 +1,30 @@ -"use node"; - -import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; import { ConvexError, v } from "convex/values"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { action, internalMutation, internalQuery, mutation, query } from "./_generated/server"; -import { getCurrentUser as getCurrentUserDoc, requireUserRole } from "./lib/auth"; +import { + action, + internalAction, + internalMutation, + internalQuery, + query, +} from "./_generated/server"; +import { getCurrentUser as getCurrentUserDoc } from "./lib/auth"; +import { GOOGLE_PROVIDER, type CalendarOwnerRole } from "./lib/calendarShared"; import { omitUndefined } from "./lib/validation"; -const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; -const GOOGLE_USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"; -const GOOGLE_EVENTS_BASE = "https://www.googleapis.com/calendar/v3/calendars/primary/events"; -const GOOGLE_PROVIDER = "google" as const; -const CALENDAR_TOKEN_ENCRYPTION_PREFIX = "enc:v1:"; -const CALENDAR_TOKEN_ENCRYPTION_SECRET_ENV = "CALENDAR_TOKEN_ENCRYPTION_SECRET"; -const calendarInternal = (internal as unknown as { calendar: Record }) - .calendar as any; - -type GoogleTokenResponse = { - access_token?: string; - expires_in?: number; - refresh_token?: string; - scope?: string; - token_type?: string; - error?: string; - error_description?: string; -}; +const calendarNodeInternal = (internal as unknown as { calendarNode: Record }) + .calendarNode as any; -type TimelineRow = { - lessonId: string; - studioName: string; - sport: string; - startTime: number; - endTime: number; - status: "open" | "filled" | "cancelled" | "completed"; +type CalendarOwnerProfile = { + role: CalendarOwnerRole; + calendarProvider: "none" | "google" | "apple"; + calendarSyncEnabled: boolean; + calendarConnectedAt?: number; + instructorId?: Id<"instructorProfiles">; + studioId?: Id<"studioProfiles">; }; -function parseScopes(scope: string | undefined): string[] { - if (!scope) { - return []; - } - return scope - .split(" ") - .map((entry: string) => entry.trim()) - .filter((entry: string) => entry.length > 0); -} - -function getAllowedGoogleClientIds() { - const csv = process.env.GOOGLE_CALENDAR_CLIENT_IDS?.trim(); - if (!csv) { - return []; - } - return csv - .split(",") - .map((entry: string) => entry.trim()) - .filter((entry: string) => entry.length > 0); -} - -function getCalendarTokenEncryptionSecret(): string | undefined { - const secret = process.env[CALENDAR_TOKEN_ENCRYPTION_SECRET_ENV]?.trim(); - return secret ? secret : undefined; -} - -function deriveCalendarTokenKey(secret: string): Buffer { - return createHash("sha256").update(secret).digest(); -} - -export function isEncryptedCalendarToken(value: string | undefined): boolean { - return Boolean(value?.startsWith(CALENDAR_TOKEN_ENCRYPTION_PREFIX)); -} - -export function encryptCalendarToken(value: string | undefined): string | undefined { - if (!value) { - return value; - } - if (isEncryptedCalendarToken(value)) { - return value; - } - const secret = getCalendarTokenEncryptionSecret(); - if (!secret) { - return value; - } - - const iv = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", deriveCalendarTokenKey(secret), iv); - const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); - const authTag = cipher.getAuthTag(); - const payload = Buffer.concat([iv, authTag, ciphertext]).toString("base64url"); - return `${CALENDAR_TOKEN_ENCRYPTION_PREFIX}${payload}`; -} - -function encryptRequiredCalendarToken(value: string): string { - return encryptCalendarToken(value) ?? value; -} - -export function decryptCalendarToken(value: string | undefined): string | undefined { - if (!value) { - return value; - } - if (!isEncryptedCalendarToken(value)) { - return value; - } - - const secret = getCalendarTokenEncryptionSecret(); - if (!secret) { - throw new ConvexError( - "Calendar token encryption secret is required to decrypt stored calendar credentials", - ); - } - - const encoded = value.slice(CALENDAR_TOKEN_ENCRYPTION_PREFIX.length); - const raw = Buffer.from(encoded, "base64url"); - if (raw.length <= 28) { - throw new ConvexError("Stored calendar token ciphertext is invalid"); - } - - const iv = raw.subarray(0, 12); - const authTag = raw.subarray(12, 28); - const ciphertext = raw.subarray(28); - - try { - const decipher = createDecipheriv("aes-256-gcm", deriveCalendarTokenKey(secret), iv); - decipher.setAuthTag(authTag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); - } catch { - throw new ConvexError("Stored calendar token could not be decrypted"); - } -} - -function assertGoogleClientIdAllowed(clientId: string) { - const allowed = getAllowedGoogleClientIds(); - if (allowed.length === 0) { - return; - } - if (!allowed.includes(clientId)) { - throw new ConvexError("Google client ID is not allowed for this environment"); - } -} - -async function exchangeGoogleAuthorizationCode(args: { - code: string; - codeVerifier: string; - redirectUri: string; - clientId: string; -}): Promise { - const body = new URLSearchParams({ - grant_type: "authorization_code", - code: args.code, - code_verifier: args.codeVerifier, - redirect_uri: args.redirectUri, - client_id: args.clientId, - }); - - const response = await fetch(GOOGLE_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - const payload = (await response.json()) as GoogleTokenResponse; - if (!response.ok || !payload.access_token) { - throw new ConvexError( - payload.error_description ?? payload.error ?? "Failed to exchange Google authorization code", - ); - } - - return payload; -} - -async function refreshGoogleAccessToken(args: { - refreshToken: string; - clientId: string; -}): Promise { - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: args.refreshToken, - client_id: args.clientId, - }); - - const response = await fetch(GOOGLE_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - const payload = (await response.json()) as GoogleTokenResponse; - if (!response.ok || !payload.access_token) { - throw new ConvexError( - payload.error_description ?? payload.error ?? "Failed to refresh Google token", - ); - } - - return payload; -} - -async function fetchGoogleAccountEmail(accessToken: string): Promise { - const response = await fetch(GOOGLE_USERINFO_ENDPOINT, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - return undefined; - } - const payload = (await response.json()) as { email?: string }; - return payload.email?.trim() || undefined; -} - -function buildGoogleEventBody(row: TimelineRow) { - return { - summary: `${row.sport} lesson`, - description: `Studio: ${row.studioName}`, - start: { - dateTime: new Date(row.startTime).toISOString(), - }, - end: { - dateTime: new Date(row.endTime).toISOString(), - }, - }; -} - -async function upsertGoogleEvent(args: { - accessToken: string; - providerEventId?: string; - row: TimelineRow; -}): Promise<{ eventId: string; etag?: string }> { - const body = JSON.stringify(buildGoogleEventBody(args.row)); - - if (args.providerEventId) { - const updateResponse = await fetch( - `${GOOGLE_EVENTS_BASE}/${encodeURIComponent(args.providerEventId)}`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${args.accessToken}`, - "Content-Type": "application/json", - }, - body, - }, - ); - - if (updateResponse.ok) { - const payload = (await updateResponse.json()) as { id?: string; etag?: string }; - if (payload.id) { - return { - eventId: payload.id, - ...omitUndefined({ etag: payload.etag }), - }; - } - } else if (updateResponse.status !== 404) { - const message = await updateResponse.text(); - throw new ConvexError(`Google update failed: ${message}`); - } - } - - const createResponse = await fetch(GOOGLE_EVENTS_BASE, { - method: "POST", - headers: { - Authorization: `Bearer ${args.accessToken}`, - "Content-Type": "application/json", - }, - body, - }); - if (!createResponse.ok) { - const message = await createResponse.text(); - throw new ConvexError(`Google create failed: ${message}`); - } - - const payload = (await createResponse.json()) as { id?: string; etag?: string }; - if (!payload.id) { - throw new ConvexError("Google event creation returned no event id"); - } - return { - eventId: payload.id, - ...omitUndefined({ etag: payload.etag }), - }; -} - -async function deleteGoogleEvent(args: { accessToken: string; providerEventId: string }) { - const response = await fetch( - `${GOOGLE_EVENTS_BASE}/${encodeURIComponent(args.providerEventId)}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${args.accessToken}` }, - }, - ); - if (response.ok || response.status === 404) { - return; - } - const message = await response.text(); - throw new ConvexError(`Google delete failed: ${message}`); -} - export const getMyGoogleCalendarStatus = query({ args: {}, returns: v.object({ @@ -302,7 +36,7 @@ export const getMyGoogleCalendarStatus = query({ }), handler: async (ctx) => { const user = await getCurrentUserDoc(ctx); - if (!user || !user.isActive || user.role !== "instructor") { + if (!user || !user.isActive || (user.role !== "instructor" && user.role !== "studio")) { return { connected: false, hasRefreshToken: false }; } @@ -329,18 +63,41 @@ export const getMyGoogleCalendarStatus = query({ }, }); -export const disconnectGoogleCalendar = mutation({ - args: {}, - returns: v.object({ ok: v.boolean() }), - handler: async (ctx) => { - const user = await requireUserRole(ctx, ["instructor"]); - const profile = await ctx.db - .query("instructorProfiles") - .withIndex("by_user_id", (q) => q.eq("userId", user._id)) - .unique(); +export const getMyGoogleCalendarAgenda = query({ + args: { + startTime: v.number(), + endTime: v.number(), + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + providerEventId: v.string(), + title: v.string(), + status: v.union( + v.literal("confirmed"), + v.literal("tentative"), + v.literal("cancelled"), + ), + startTime: v.number(), + endTime: v.number(), + isAllDay: v.boolean(), + location: v.optional(v.string()), + htmlLink: v.optional(v.string()), + timeZone: v.optional(v.string()), + providerUpdatedAt: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + if (!Number.isFinite(args.startTime) || !Number.isFinite(args.endTime)) { + throw new ConvexError("startTime and endTime must be finite numbers"); + } + if (args.endTime < args.startTime) { + throw new ConvexError("endTime must be greater than or equal to startTime"); + } - if (!profile) { - throw new ConvexError("Instructor profile not found"); + const user = await getCurrentUserDoc(ctx); + if (!user || !user.isActive || (user.role !== "instructor" && user.role !== "studio")) { + return []; } const integration = await ctx.db @@ -349,23 +106,36 @@ export const disconnectGoogleCalendar = mutation({ q.eq("userId", user._id).eq("provider", GOOGLE_PROVIDER), ) .unique(); - - if (integration) { - const mappings = await ctx.db - .query("calendarEventMappings") - .withIndex("by_integration", (q) => q.eq("integrationId", integration._id)) - .collect(); - await Promise.all(mappings.map((mapping) => ctx.db.delete(mapping._id))); - await ctx.db.delete(integration._id); + if (!integration || integration.status !== "connected") { + return []; } - await ctx.db.patch(profile._id, { - calendarProvider: "none", - calendarSyncEnabled: false, - updatedAt: Date.now(), - }); + const rawLimit = args.limit ?? 1000; + const limit = Math.max(1, Math.min(rawLimit, 1000)); + const rows = await ctx.db + .query("calendarExternalEvents") + .withIndex("by_integration_start_time", (q) => + q + .eq("integrationId", integration._id) + .gte("startTime", args.startTime) + .lte("startTime", args.endTime), + ) + .take(limit); - return { ok: true }; + return rows.map((row) => ({ + providerEventId: row.providerEventId, + title: row.title, + status: row.status, + startTime: row.startTime, + endTime: row.endTime, + isAllDay: row.isAllDay, + ...omitUndefined({ + location: row.location, + htmlLink: row.htmlLink, + timeZone: row.timeZone, + providerUpdatedAt: row.providerUpdatedAt, + }), + })); }, }); @@ -382,56 +152,7 @@ export const connectGoogleCalendarWithCode = action({ accountEmail: v.optional(v.string()), }), handler: async (ctx, args) => { - const currentUser = await ctx.runQuery(api.users.getCurrentUser, {}); - if (!currentUser || currentUser.role !== "instructor") { - throw new ConvexError("Only instructors can connect Google Calendar"); - } - - assertGoogleClientIdAllowed(args.clientId); - - const profile = await ctx.runQuery(calendarInternal.getInstructorProfileForUser, { - userId: currentUser._id, - }); - if (!profile) { - throw new ConvexError("Instructor profile not found"); - } - - const existingIntegration = await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { - userId: currentUser._id, - }); - - const token = await exchangeGoogleAuthorizationCode({ - code: args.code, - codeVerifier: args.codeVerifier, - redirectUri: args.redirectUri, - clientId: args.clientId, - }); - - const refreshToken = token.refresh_token ?? existingIntegration?.refreshToken; - const accessToken = token.access_token; - if (!accessToken) { - throw new ConvexError("Google access token was missing from authorization response"); - } - const accountEmail = await fetchGoogleAccountEmail(accessToken); - - await ctx.runMutation(calendarInternal.upsertGoogleIntegration, { - userId: currentUser._id, - instructorId: profile._id, - accountEmail, - oauthClientId: args.clientId, - accessToken, - refreshToken, - accessTokenExpiresAt: Date.now() + Math.max(60, token.expires_in ?? 3600) * 1000, - scopes: parseScopes(token.scope), - enableSync: true, - clearError: true, - }); - - return { - ok: true, - connected: true, - ...omitUndefined({ accountEmail }), - }; + return await ctx.runAction(calendarNodeInternal.connectGoogleCalendarWithCodeInternal, args); }, }); @@ -445,140 +166,243 @@ export const syncMyGoogleCalendarEvents = action({ ok: v.boolean(), syncedCount: v.number(), removedCount: v.number(), + importedCount: v.number(), + importedRemovedCount: v.number(), }), handler: async (ctx, args) => { - const now = Date.now(); - const currentUser = await ctx.runQuery(api.users.getCurrentUser, {}); - if (!currentUser || currentUser.role !== "instructor") { - throw new ConvexError("Only instructors can sync Google Calendar"); - } - - const integration = await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { - userId: currentUser._id, - }); - if (!integration || integration.status !== "connected") { - throw new ConvexError("Google Calendar is not connected"); - } - if (!integration.refreshToken || !integration.oauthClientId) { - throw new ConvexError("Google Calendar integration is missing refresh credentials"); - } + return await ctx.runAction(calendarNodeInternal.syncMyGoogleCalendarEventsInternal, args); + }, +}); - try { - let accessToken = integration.accessToken ?? ""; - let accessTokenExpiresAt = integration.accessTokenExpiresAt ?? 0; - if (!accessToken || accessTokenExpiresAt < now + 60_000) { - const refreshed = await refreshGoogleAccessToken({ - refreshToken: integration.refreshToken, - clientId: integration.oauthClientId, - }); - accessToken = refreshed.access_token ?? ""; - accessTokenExpiresAt = now + Math.max(60, refreshed.expires_in ?? 3600) * 1000; - - await ctx.runMutation(calendarInternal.updateGoogleAccessToken, { - integrationId: integration._id, - accessToken, - accessTokenExpiresAt, - scopes: parseScopes(refreshed.scope), - }); - } +export const disconnectGoogleCalendar = action({ + args: {}, + returns: v.object({ + ok: v.boolean(), + deletedRemoteEvents: v.boolean(), + }), + handler: async (ctx) => { + return await ctx.runAction(calendarNodeInternal.disconnectGoogleCalendarInternal, {}); + }, +}); - const startTime = args.startTime ?? now - 7 * 24 * 60 * 60 * 1000; - const endTime = args.endTime ?? now + 90 * 24 * 60 * 60 * 1000; - const limit = Math.max(50, Math.min(1000, args.limit ?? 400)); - const timeline = (await ctx.runQuery(api.jobs.getMyCalendarTimeline, { - startTime, - endTime, - limit, - })) as TimelineRow[]; - - const targetRows = timeline - .filter((row) => row.status !== "cancelled" && row.endTime >= now - 7 * 24 * 60 * 60 * 1000) - .sort((a, b) => a.startTime - b.startTime); - - const existingMappings = (await ctx.runQuery( - calendarInternal.getEventMappingsForIntegration, - { - integrationId: integration._id, - }, - )) as Array<{ externalEventId: string; providerEventId: string }>; - const mappingByExternalId = new Map( - existingMappings.map((mapping: { externalEventId: string; providerEventId: string }) => [ - mapping.externalEventId, - mapping.providerEventId, - ]), - ); - - const nextMappings: Array<{ - externalEventId: string; - providerEventId: string; - providerEtag?: string; - startTime: number; - endTime: number; - }> = []; - - for (const row of targetRows) { - const updated = await upsertGoogleEvent({ - accessToken, - ...omitUndefined({ providerEventId: mappingByExternalId.get(row.lessonId) }), - row, - }); - nextMappings.push({ - externalEventId: row.lessonId, - providerEventId: updated.eventId, - ...omitUndefined({ providerEtag: updated.etag }), - startTime: row.startTime, - endTime: row.endTime, - }); - } +export const syncGoogleCalendarForUser = internalAction({ + args: { + userId: v.id("users"), + startTime: v.optional(v.number()), + endTime: v.optional(v.number()), + limit: v.optional(v.number()), + }, + returns: v.object({ + ok: v.boolean(), + syncedCount: v.number(), + removedCount: v.number(), + importedCount: v.number(), + importedRemovedCount: v.number(), + }), + handler: async (ctx, args) => { + return await ctx.runAction(calendarNodeInternal.syncGoogleCalendarForUserInternal, args); + }, +}); - const activeExternalIds = new Set(nextMappings.map((mapping) => mapping.externalEventId)); - let removedCount = 0; - for (const mapping of existingMappings) { - if (activeExternalIds.has(mapping.externalEventId)) { - continue; +export const getCalendarProfileForUser = internalQuery({ + args: { userId: v.id("users") }, + returns: v.union( + v.object({ + role: v.literal("instructor"), + calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), + calendarSyncEnabled: v.boolean(), + calendarConnectedAt: v.optional(v.number()), + instructorId: v.id("instructorProfiles"), + }), + v.object({ + role: v.literal("studio"), + calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), + calendarSyncEnabled: v.boolean(), + calendarConnectedAt: v.optional(v.number()), + studioId: v.id("studioProfiles"), + }), + v.null(), + ), + handler: async (ctx, args) => { + const instructorProfile = (await ctx.db + .query("instructorProfiles") + .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) + .unique()) as + | { + _id: Id<"instructorProfiles">; + calendarProvider?: "none" | "google" | "apple"; + calendarSyncEnabled?: boolean; + calendarConnectedAt?: number; } - await deleteGoogleEvent({ accessToken, providerEventId: mapping.providerEventId }); - removedCount += 1; - } - - await ctx.runMutation(calendarInternal.replaceEventMappingsForIntegration, { - integrationId: integration._id, - mappings: nextMappings, - }); - await ctx.runMutation(calendarInternal.markGoogleSyncResult, { - integrationId: integration._id, - lastSyncedAt: Date.now(), - lastError: undefined, - }); - + | null; + if (instructorProfile) { return { - ok: true, - syncedCount: nextMappings.length, - removedCount, + role: "instructor" as const, + calendarProvider: instructorProfile.calendarProvider ?? "none", + calendarSyncEnabled: instructorProfile.calendarSyncEnabled ?? false, + ...(instructorProfile.calendarConnectedAt !== undefined + ? { calendarConnectedAt: instructorProfile.calendarConnectedAt } + : {}), + instructorId: instructorProfile._id, }; - } catch (error) { - const message = error instanceof Error ? error.message : "Google Calendar sync failed"; - await ctx.runMutation(calendarInternal.markGoogleSyncResult, { - integrationId: integration._id, - lastError: message, - }); - throw error; } + + const studioProfile = (await ctx.db + .query("studioProfiles") + .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) + .unique()) as + | { + _id: Id<"studioProfiles">; + calendarProvider?: "none" | "google" | "apple"; + calendarSyncEnabled?: boolean; + calendarConnectedAt?: number; + } + | null; + if (!studioProfile) { + return null; + } + + return { + role: "studio" as const, + calendarProvider: studioProfile.calendarProvider ?? "none", + calendarSyncEnabled: studioProfile.calendarSyncEnabled ?? false, + ...(studioProfile.calendarConnectedAt !== undefined + ? { calendarConnectedAt: studioProfile.calendarConnectedAt } + : {}), + studioId: studioProfile._id, + }; }, }); -export const getInstructorProfileForUser = internalQuery({ - args: { userId: v.id("users") }, - returns: v.union(v.object({ _id: v.id("instructorProfiles") }), v.null()), +export const getCalendarTimelineForUser = internalQuery({ + args: { + userId: v.id("users"), + startTime: v.number(), + endTime: v.number(), + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + lessonId: v.id("jobs"), + roleView: v.union(v.literal("instructor"), v.literal("studio")), + studioName: v.string(), + instructorName: v.optional(v.string()), + sport: v.string(), + startTime: v.number(), + endTime: v.number(), + timeZone: v.optional(v.string()), + status: v.union( + v.literal("open"), + v.literal("filled"), + v.literal("cancelled"), + v.literal("completed"), + ), + }), + ), handler: async (ctx, args) => { - const profile = await ctx.db + if (!Number.isFinite(args.startTime) || !Number.isFinite(args.endTime)) { + throw new ConvexError("startTime and endTime must be finite numbers"); + } + if (args.endTime < args.startTime) { + throw new ConvexError("endTime must be greater than or equal to startTime"); + } + + const rawLimit = args.limit ?? 400; + const limit = Math.max(1, Math.min(rawLimit, 1000)); + + const instructorProfile = (await ctx.db .query("instructorProfiles") .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) - .unique(); - if (!profile) { - return null; + .unique()) as { _id: Id<"instructorProfiles">; displayName: string } | null; + if (instructorProfile) { + const jobs = await ctx.db + .query("jobs") + .withIndex("by_filledByInstructor_startTime", (q) => + q + .eq("filledByInstructorId", instructorProfile._id) + .gte("startTime", args.startTime) + .lte("startTime", args.endTime), + ) + .order("asc") + .take(limit); + + const studioIds = [...new Set(jobs.map((job) => job.studioId))]; + const studios = await Promise.all(studioIds.map((studioId) => ctx.db.get(studioId))); + const studioNameById = new Map(); + for (let index = 0; index < studioIds.length; index += 1) { + const studioId = studioIds[index]; + const studio = studios[index] as { studioName?: string } | null; + if (studio?.studioName) { + studioNameById.set(String(studioId), studio.studioName); + } + } + + return jobs.map((job) => ({ + lessonId: job._id, + roleView: "instructor" as const, + studioName: studioNameById.get(String(job.studioId)) ?? "Unknown studio", + instructorName: instructorProfile.displayName, + sport: job.sport, + startTime: job.startTime, + endTime: job.endTime, + status: job.status, + ...(job.timeZone ? { timeZone: job.timeZone } : {}), + })); + } + + const studioProfile = (await ctx.db + .query("studioProfiles") + .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) + .unique()) as { _id: Id<"studioProfiles">; studioName: string } | null; + if (!studioProfile) { + return []; + } + + const jobs = await ctx.db + .query("jobs") + .withIndex("by_studio_startTime", (q) => + q + .eq("studioId", studioProfile._id) + .gte("startTime", args.startTime) + .lte("startTime", args.endTime), + ) + .order("asc") + .take(limit); + + const instructorIds = [ + ...new Set( + jobs + .map((job) => job.filledByInstructorId) + .filter((id): id is Id<"instructorProfiles"> => Boolean(id)), + ), + ]; + const instructors = await Promise.all( + instructorIds.map((instructorId) => ctx.db.get(instructorId)), + ); + const instructorNameById = new Map(); + for (let index = 0; index < instructorIds.length; index += 1) { + const instructorId = instructorIds[index]; + const instructor = instructors[index] as { displayName?: string } | null; + if (instructor?.displayName) { + instructorNameById.set(String(instructorId), instructor.displayName); + } } - return { _id: profile._id }; + + return jobs.map((job) => ({ + lessonId: job._id, + roleView: "studio" as const, + studioName: studioProfile.studioName, + ...omitUndefined({ + instructorName: job.filledByInstructorId + ? instructorNameById.get(String(job.filledByInstructorId)) + : undefined, + }), + sport: job.sport, + startTime: job.startTime, + endTime: job.endTime, + status: job.status, + ...(job.timeZone ? { timeZone: job.timeZone } : {}), + })); }, }); @@ -587,11 +411,15 @@ export const getGoogleIntegrationForUser = internalQuery({ returns: v.union( v.object({ _id: v.id("calendarIntegrations"), + role: v.union(v.literal("instructor"), v.literal("studio")), status: v.union(v.literal("connected"), v.literal("error"), v.literal("revoked")), + instructorId: v.optional(v.id("instructorProfiles")), + studioId: v.optional(v.id("studioProfiles")), accessToken: v.optional(v.string()), refreshToken: v.optional(v.string()), oauthClientId: v.optional(v.string()), accessTokenExpiresAt: v.optional(v.number()), + agendaSyncToken: v.optional(v.string()), }), v.null(), ), @@ -605,14 +433,21 @@ export const getGoogleIntegrationForUser = internalQuery({ if (!integration) { return null; } + const inferredRole = + integration.role ?? + (integration.studioId ? ("studio" as const) : ("instructor" as const)); return { _id: integration._id, + role: inferredRole, status: integration.status, ...omitUndefined({ - accessToken: decryptCalendarToken(integration.accessToken), - refreshToken: decryptCalendarToken(integration.refreshToken), + instructorId: integration.instructorId, + studioId: integration.studioId, + accessToken: integration.accessToken, + refreshToken: integration.refreshToken, oauthClientId: integration.oauthClientId, accessTokenExpiresAt: integration.accessTokenExpiresAt, + agendaSyncToken: integration.agendaSyncToken, }), }; }, @@ -641,7 +476,9 @@ export const getEventMappingsForIntegration = internalQuery({ export const upsertGoogleIntegration = internalMutation({ args: { userId: v.id("users"), - instructorId: v.id("instructorProfiles"), + role: v.union(v.literal("instructor"), v.literal("studio")), + instructorId: v.optional(v.id("instructorProfiles")), + studioId: v.optional(v.id("studioProfiles")), accountEmail: v.optional(v.string()), oauthClientId: v.string(), accessToken: v.string(), @@ -663,12 +500,18 @@ export const upsertGoogleIntegration = internalMutation({ const patch = { status: "connected" as const, - accountEmail: args.accountEmail, + role: args.role, oauthClientId: args.oauthClientId, - accessToken: encryptRequiredCalendarToken(args.accessToken), + accessToken: args.accessToken, accessTokenExpiresAt: args.accessTokenExpiresAt, scopes: args.scopes, - ...(args.refreshToken ? { refreshToken: encryptCalendarToken(args.refreshToken) } : {}), + agendaSyncToken: undefined, + ...omitUndefined({ + accountEmail: args.accountEmail, + instructorId: args.instructorId, + studioId: args.studioId, + refreshToken: args.refreshToken, + }), ...(args.clearError ? { lastError: undefined } : {}), updatedAt: now, }; @@ -680,15 +523,17 @@ export const upsertGoogleIntegration = internalMutation({ } else { integrationId = await ctx.db.insert("calendarIntegrations", { userId: args.userId, - instructorId: args.instructorId, + role: args.role, provider: GOOGLE_PROVIDER, status: "connected", ...omitUndefined({ + instructorId: args.instructorId, + studioId: args.studioId, accountEmail: args.accountEmail, - refreshToken: encryptCalendarToken(args.refreshToken), + refreshToken: args.refreshToken, }), oauthClientId: args.oauthClientId, - accessToken: encryptRequiredCalendarToken(args.accessToken), + accessToken: args.accessToken, accessTokenExpiresAt: args.accessTokenExpiresAt, scopes: args.scopes, createdAt: now, @@ -696,12 +541,27 @@ export const upsertGoogleIntegration = internalMutation({ }); } - await ctx.db.patch(args.instructorId, { - calendarProvider: "google", - calendarSyncEnabled: args.enableSync, - calendarConnectedAt: now, - updatedAt: now, - }); + if (args.role === "instructor") { + if (!args.instructorId) { + throw new ConvexError("Instructor profile is required for instructor calendar integration"); + } + await ctx.db.patch(args.instructorId, { + calendarProvider: "google", + calendarSyncEnabled: args.enableSync, + calendarConnectedAt: now, + updatedAt: now, + }); + } else { + if (!args.studioId) { + throw new ConvexError("Studio profile is required for studio calendar integration"); + } + await ctx.db.patch(args.studioId, { + calendarProvider: "google", + calendarSyncEnabled: args.enableSync, + calendarConnectedAt: now, + updatedAt: now, + }); + } return integrationId; }, @@ -717,7 +577,7 @@ export const updateGoogleAccessToken = internalMutation({ returns: v.object({ ok: v.boolean() }), handler: async (ctx, args) => { await ctx.db.patch(args.integrationId, { - accessToken: encryptRequiredCalendarToken(args.accessToken), + accessToken: args.accessToken, accessTokenExpiresAt: args.accessTokenExpiresAt, scopes: args.scopes, updatedAt: Date.now(), @@ -784,3 +644,178 @@ export const replaceEventMappingsForIntegration = internalMutation({ return { ok: true }; }, }); + +export const applyGoogleAgendaSyncResult = internalMutation({ + args: { + integrationId: v.id("calendarIntegrations"), + nextSyncToken: v.optional(v.string()), + resetImportedEvents: v.boolean(), + events: v.array( + v.object({ + providerEventId: v.string(), + title: v.string(), + status: v.union( + v.literal("confirmed"), + v.literal("tentative"), + v.literal("cancelled"), + ), + startTime: v.number(), + endTime: v.number(), + isAllDay: v.boolean(), + location: v.optional(v.string()), + htmlLink: v.optional(v.string()), + timeZone: v.optional(v.string()), + providerUpdatedAt: v.optional(v.number()), + }), + ), + deletedProviderEventIds: v.array(v.string()), + }, + returns: v.object({ + importedCount: v.number(), + removedCount: v.number(), + }), + handler: async (ctx, args) => { + const now = Date.now(); + let removedCount = 0; + + if (args.resetImportedEvents) { + const existing = await ctx.db + .query("calendarExternalEvents") + .withIndex("by_integration", (q) => q.eq("integrationId", args.integrationId)) + .collect(); + removedCount += existing.length; + await Promise.all(existing.map((row) => ctx.db.delete(row._id))); + } + + for (const providerEventId of args.deletedProviderEventIds) { + const existing = await ctx.db + .query("calendarExternalEvents") + .withIndex("by_integration_provider_event", (q) => + q.eq("integrationId", args.integrationId).eq("providerEventId", providerEventId), + ) + .unique(); + if (!existing) { + continue; + } + await ctx.db.delete(existing._id); + removedCount += 1; + } + + for (const event of args.events) { + const existing = await ctx.db + .query("calendarExternalEvents") + .withIndex("by_integration_provider_event", (q) => + q.eq("integrationId", args.integrationId).eq("providerEventId", event.providerEventId), + ) + .unique(); + + const patch = { + title: event.title, + status: event.status, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay, + ...omitUndefined({ + location: event.location, + htmlLink: event.htmlLink, + timeZone: event.timeZone, + providerUpdatedAt: event.providerUpdatedAt, + }), + updatedAt: now, + }; + + if (existing) { + await ctx.db.patch(existing._id, patch); + } else { + await ctx.db.insert("calendarExternalEvents", { + integrationId: args.integrationId, + providerEventId: event.providerEventId, + title: event.title, + status: event.status, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay, + ...omitUndefined({ + location: event.location, + htmlLink: event.htmlLink, + timeZone: event.timeZone, + providerUpdatedAt: event.providerUpdatedAt, + }), + createdAt: now, + updatedAt: now, + }); + } + } + + await ctx.db.patch(args.integrationId, { + agendaSyncToken: args.nextSyncToken, + updatedAt: now, + }); + + return { + importedCount: args.events.length, + removedCount, + }; + }, +}); + +export const disconnectGoogleIntegrationLocally = internalMutation({ + args: { userId: v.id("users") }, + returns: v.object({ ok: v.boolean() }), + handler: async (ctx, args) => { + const profile = (await ctx.runQuery(internal.calendar.getCalendarProfileForUser, { + userId: args.userId, + })) as CalendarOwnerProfile | null; + if (!profile) { + throw new ConvexError("Calendar profile not found"); + } + + const integration = await ctx.db + .query("calendarIntegrations") + .withIndex("by_user_provider", (q) => + q.eq("userId", args.userId).eq("provider", GOOGLE_PROVIDER), + ) + .unique(); + + if (integration) { + const [mappings, importedEvents] = await Promise.all([ + ctx.db + .query("calendarEventMappings") + .withIndex("by_integration", (q) => q.eq("integrationId", integration._id)) + .collect(), + ctx.db + .query("calendarExternalEvents") + .withIndex("by_integration", (q) => q.eq("integrationId", integration._id)) + .collect(), + ]); + + await Promise.all([ + ...mappings.map((mapping) => ctx.db.delete(mapping._id)), + ...importedEvents.map((row) => ctx.db.delete(row._id)), + ]); + await ctx.db.delete(integration._id); + } + + if (profile.role === "instructor") { + if (!profile.instructorId) { + throw new ConvexError("Instructor profile not found"); + } + await ctx.db.patch(profile.instructorId, { + calendarProvider: "none", + calendarSyncEnabled: false, + updatedAt: Date.now(), + }); + } else { + if (!profile.studioId) { + throw new ConvexError("Studio profile not found"); + } + await ctx.db.patch(profile.studioId, { + calendarProvider: "none", + calendarSyncEnabled: false, + updatedAt: Date.now(), + }); + } + + return { ok: true }; + }, +}); diff --git a/convex/calendarNode.ts b/convex/calendarNode.ts new file mode 100644 index 00000000..01eea34a --- /dev/null +++ b/convex/calendarNode.ts @@ -0,0 +1,753 @@ +"use node"; + +import { ConvexError, v } from "convex/values"; + +import { api, internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import { internalAction } from "./_generated/server"; +import { + decryptCalendarToken, + encryptCalendarToken, + encryptRequiredCalendarToken, +} from "./lib/calendarCrypto"; +import { + buildGoogleEventBody, + isQueueManagedGoogleEvent, + normalizeImportedGoogleEvent, + type CalendarOwnerRole, + type GoogleCalendarEvent, + type ImportedGoogleCalendarEvent, + type TimelineRow, +} from "./lib/calendarShared"; +import { omitUndefined } from "./lib/validation"; + +const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"; +const GOOGLE_EVENTS_BASE = "https://www.googleapis.com/calendar/v3/calendars/primary/events"; +const GOOGLE_EVENTS_LIST_PAGE_SIZE = 250; +const calendarInternal = (internal as unknown as { calendar: Record }) + .calendar as any; + +type GoogleTokenResponse = { + access_token?: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + token_type?: string; + error?: string; + error_description?: string; +}; + +type GoogleIntegrationRecord = { + _id: Id<"calendarIntegrations">; + role: CalendarOwnerRole; + status: "connected" | "error" | "revoked"; + instructorId?: Id<"instructorProfiles">; + studioId?: Id<"studioProfiles">; + accessToken?: string; + refreshToken?: string; + oauthClientId?: string; + accessTokenExpiresAt?: number; + agendaSyncToken?: string; +}; + +type CalendarOwnerProfile = { + role: CalendarOwnerRole; + calendarProvider: "none" | "google" | "apple"; + calendarSyncEnabled: boolean; + calendarConnectedAt?: number; + instructorId?: Id<"instructorProfiles">; + studioId?: Id<"studioProfiles">; +}; + +function parseScopes(scope: string | undefined): string[] { + if (!scope) { + return []; + } + return scope + .split(" ") + .map((entry: string) => entry.trim()) + .filter((entry: string) => entry.length > 0); +} + +function getAllowedGoogleClientIds() { + const csv = process.env.GOOGLE_CALENDAR_CLIENT_IDS?.trim(); + if (!csv) { + return []; + } + return csv + .split(",") + .map((entry: string) => entry.trim()) + .filter((entry: string) => entry.length > 0); +} + +function assertGoogleClientIdAllowed(clientId: string) { + const allowed = getAllowedGoogleClientIds(); + if (allowed.length === 0) { + return; + } + if (!allowed.includes(clientId)) { + throw new ConvexError("Google client ID is not allowed for this environment"); + } +} + +async function exchangeGoogleAuthorizationCode(args: { + code: string; + codeVerifier: string; + redirectUri: string; + clientId: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: args.code, + code_verifier: args.codeVerifier, + redirect_uri: args.redirectUri, + client_id: args.clientId, + }); + + const response = await fetch(GOOGLE_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + const payload = (await response.json()) as GoogleTokenResponse; + if (!response.ok || !payload.access_token) { + throw new ConvexError( + payload.error_description ?? payload.error ?? "Failed to exchange Google authorization code", + ); + } + + return payload; +} + +async function refreshGoogleAccessToken(args: { + refreshToken: string; + clientId: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: args.refreshToken, + client_id: args.clientId, + }); + + const response = await fetch(GOOGLE_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + const payload = (await response.json()) as GoogleTokenResponse; + if (!response.ok || !payload.access_token) { + throw new ConvexError( + payload.error_description ?? payload.error ?? "Failed to refresh Google token", + ); + } + + return payload; +} + +async function fetchGoogleAccountEmail(accessToken: string): Promise { + const response = await fetch(GOOGLE_USERINFO_ENDPOINT, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + return undefined; + } + const payload = (await response.json()) as { email?: string }; + return payload.email?.trim() || undefined; +} + +async function upsertGoogleEvent(args: { + accessToken: string; + providerEventId?: string; + row: TimelineRow; +}): Promise<{ eventId: string; etag?: string }> { + const body = JSON.stringify(buildGoogleEventBody(args.row)); + + if (args.providerEventId) { + const updateResponse = await fetch( + `${GOOGLE_EVENTS_BASE}/${encodeURIComponent(args.providerEventId)}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${args.accessToken}`, + "Content-Type": "application/json", + }, + body, + }, + ); + + if (updateResponse.ok) { + const payload = (await updateResponse.json()) as { id?: string; etag?: string }; + if (payload.id) { + return { + eventId: payload.id, + ...omitUndefined({ etag: payload.etag }), + }; + } + } else if (updateResponse.status !== 404) { + const message = await updateResponse.text(); + throw new ConvexError(`Google update failed: ${message}`); + } + } + + const createResponse = await fetch(GOOGLE_EVENTS_BASE, { + method: "POST", + headers: { + Authorization: `Bearer ${args.accessToken}`, + "Content-Type": "application/json", + }, + body, + }); + if (!createResponse.ok) { + const message = await createResponse.text(); + throw new ConvexError(`Google create failed: ${message}`); + } + + const payload = (await createResponse.json()) as { id?: string; etag?: string }; + if (!payload.id) { + throw new ConvexError("Google event creation returned no event id"); + } + return { + eventId: payload.id, + ...omitUndefined({ etag: payload.etag }), + }; +} + +async function deleteGoogleEvent(args: { accessToken: string; providerEventId: string }) { + const response = await fetch( + `${GOOGLE_EVENTS_BASE}/${encodeURIComponent(args.providerEventId)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${args.accessToken}` }, + }, + ); + if (response.ok || response.status === 404) { + return; + } + const message = await response.text(); + throw new ConvexError(`Google delete failed: ${message}`); +} + +async function listGoogleAgendaChanges(args: { + accessToken: string; + syncToken?: string; +}): Promise<{ + events: GoogleCalendarEvent[]; + nextSyncToken?: string; + resetImportedEvents: boolean; +}> { + let syncToken = args.syncToken; + let resetImportedEvents = !syncToken; + + while (true) { + const params = new URLSearchParams({ + maxResults: String(GOOGLE_EVENTS_LIST_PAGE_SIZE), + showDeleted: "true", + singleEvents: "true", + }); + if (syncToken) { + params.set("syncToken", syncToken); + } + + let pageToken: string | undefined; + const events: GoogleCalendarEvent[] = []; + let nextSyncToken: string | undefined; + + while (true) { + const pageParams = new URLSearchParams(params); + if (pageToken) { + pageParams.set("pageToken", pageToken); + } + + const response = await fetch(`${GOOGLE_EVENTS_BASE}?${pageParams.toString()}`, { + headers: { Authorization: `Bearer ${args.accessToken}` }, + }); + if (response.status === 410 && syncToken) { + syncToken = undefined; + resetImportedEvents = true; + break; + } + if (!response.ok) { + const message = await response.text(); + throw new ConvexError(`Google agenda import failed: ${message}`); + } + + const payload = (await response.json()) as { + items?: GoogleCalendarEvent[]; + nextPageToken?: string; + nextSyncToken?: string; + }; + events.push(...(payload.items ?? [])); + if (payload.nextPageToken) { + pageToken = payload.nextPageToken; + continue; + } + + nextSyncToken = payload.nextSyncToken; + return nextSyncToken + ? { events, nextSyncToken, resetImportedEvents } + : { events, resetImportedEvents }; + } + } +} + +async function getGoogleAccessToken( + ctx: any, + integration: GoogleIntegrationRecord, + now: number, +) { + let accessToken = decryptCalendarToken(integration.accessToken) ?? ""; + let accessTokenExpiresAt = integration.accessTokenExpiresAt ?? 0; + if (!accessToken || accessTokenExpiresAt < now + 60_000) { + const refreshToken = decryptCalendarToken(integration.refreshToken); + if (!refreshToken || !integration.oauthClientId) { + throw new ConvexError("Google Calendar integration is missing refresh credentials"); + } + const refreshed = await refreshGoogleAccessToken({ + refreshToken, + clientId: integration.oauthClientId, + }); + accessToken = refreshed.access_token ?? ""; + accessTokenExpiresAt = now + Math.max(60, refreshed.expires_in ?? 3600) * 1000; + + await ctx.runMutation(calendarInternal.updateGoogleAccessToken, { + integrationId: integration._id, + accessToken: encryptRequiredCalendarToken(accessToken), + accessTokenExpiresAt, + scopes: parseScopes(refreshed.scope), + }); + } + + return accessToken; +} + +async function syncQueueEventsToGoogle(args: { + ctx: any; + userId: Id<"users">; + integrationId: Id<"calendarIntegrations">; + accessToken: string; + now: number; + startTime?: number; + endTime?: number; + limit?: number; +}) { + const startTime = args.startTime ?? args.now - 7 * 24 * 60 * 60 * 1000; + const endTime = args.endTime ?? args.now + 90 * 24 * 60 * 60 * 1000; + const limit = Math.max(50, Math.min(1000, args.limit ?? 400)); + const timeline = (await args.ctx.runQuery(calendarInternal.getCalendarTimelineForUser, { + userId: args.userId, + startTime, + endTime, + limit, + })) as TimelineRow[]; + + const targetRows = timeline + .filter( + (row) => row.status !== "cancelled" && row.endTime >= args.now - 7 * 24 * 60 * 60 * 1000, + ) + .sort((a, b) => a.startTime - b.startTime); + + const existingMappings = (await args.ctx.runQuery(calendarInternal.getEventMappingsForIntegration, { + integrationId: args.integrationId, + })) as Array<{ externalEventId: string; providerEventId: string }>; + const mappingByExternalId = new Map( + existingMappings.map((mapping) => [mapping.externalEventId, mapping.providerEventId]), + ); + + const nextMappings: Array<{ + externalEventId: string; + providerEventId: string; + providerEtag?: string; + startTime: number; + endTime: number; + }> = []; + + for (const row of targetRows) { + const updated = await upsertGoogleEvent({ + accessToken: args.accessToken, + ...omitUndefined({ providerEventId: mappingByExternalId.get(row.lessonId) }), + row, + }); + nextMappings.push({ + externalEventId: row.lessonId, + providerEventId: updated.eventId, + ...omitUndefined({ providerEtag: updated.etag }), + startTime: row.startTime, + endTime: row.endTime, + }); + } + + const activeExternalIds = new Set(nextMappings.map((mapping) => mapping.externalEventId)); + let removedCount = 0; + for (const mapping of existingMappings) { + if (activeExternalIds.has(mapping.externalEventId)) { + continue; + } + await deleteGoogleEvent({ + accessToken: args.accessToken, + providerEventId: mapping.providerEventId, + }); + removedCount += 1; + } + + await args.ctx.runMutation(calendarInternal.replaceEventMappingsForIntegration, { + integrationId: args.integrationId, + mappings: nextMappings, + }); + + return { + syncedCount: nextMappings.length, + removedCount, + mappedProviderEventIds: new Set(nextMappings.map((mapping) => mapping.providerEventId)), + }; +} + +async function syncGoogleAgendaIntoConvex(args: { + ctx: any; + integration: GoogleIntegrationRecord; + accessToken: string; + mappedProviderEventIds: ReadonlySet; +}) { + const imported = await listGoogleAgendaChanges({ + accessToken: args.accessToken, + ...(args.integration.agendaSyncToken + ? { syncToken: args.integration.agendaSyncToken } + : {}), + }); + + const nextEvents: ImportedGoogleCalendarEvent[] = []; + const deletedProviderEventIds = new Set(); + for (const event of imported.events) { + const providerEventId = event.id?.trim(); + if (!providerEventId) { + continue; + } + if (event.status === "cancelled") { + deletedProviderEventIds.add(providerEventId); + continue; + } + if (isQueueManagedGoogleEvent(event, args.mappedProviderEventIds)) { + deletedProviderEventIds.add(providerEventId); + continue; + } + + const normalized = normalizeImportedGoogleEvent(event); + if (!normalized) { + deletedProviderEventIds.add(providerEventId); + continue; + } + nextEvents.push(normalized); + } + + const result = (await args.ctx.runMutation(calendarInternal.applyGoogleAgendaSyncResult, { + integrationId: args.integration._id, + nextSyncToken: imported.nextSyncToken, + resetImportedEvents: imported.resetImportedEvents, + events: nextEvents, + deletedProviderEventIds: Array.from(deletedProviderEventIds), + })) as { + importedCount: number; + removedCount: number; + }; + + return { + importedCount: result.importedCount, + importedRemovedCount: result.removedCount, + }; +} + +async function runGoogleCalendarSync( + ctx: any, + args: { + userId: Id<"users">; + startTime?: number; + endTime?: number; + limit?: number; + requireConnected: boolean; + }, +) { + const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { + userId: args.userId, + })) as GoogleIntegrationRecord | null; + if (!integration || integration.status !== "connected") { + if (args.requireConnected) { + throw new ConvexError("Google Calendar is not connected"); + } + return { + ok: true, + syncedCount: 0, + removedCount: 0, + importedCount: 0, + importedRemovedCount: 0, + }; + } + + const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, { + userId: args.userId, + })) as CalendarOwnerProfile | null; + if (!profile) { + if (args.requireConnected) { + throw new ConvexError("Calendar profile not found"); + } + return { + ok: true, + syncedCount: 0, + removedCount: 0, + importedCount: 0, + importedRemovedCount: 0, + }; + } + + const now = Date.now(); + try { + const accessToken = await getGoogleAccessToken(ctx, integration, now); + const existingMappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, { + integrationId: integration._id, + })) as Array<{ externalEventId: string; providerEventId: string }>; + let pushResult = { + syncedCount: 0, + removedCount: 0, + mappedProviderEventIds: new Set(existingMappings.map((mapping) => mapping.providerEventId)), + }; + + if (profile.calendarProvider === "google" && profile.calendarSyncEnabled) { + pushResult = await syncQueueEventsToGoogle({ + ctx, + userId: args.userId, + integrationId: integration._id, + accessToken, + now, + ...omitUndefined({ + startTime: args.startTime, + endTime: args.endTime, + limit: args.limit, + }), + }); + } + + const agendaResult = await syncGoogleAgendaIntoConvex({ + ctx, + integration, + accessToken, + mappedProviderEventIds: pushResult.mappedProviderEventIds, + }); + + await ctx.runMutation(calendarInternal.markGoogleSyncResult, { + integrationId: integration._id, + lastSyncedAt: Date.now(), + lastError: undefined, + }); + + return { + ok: true, + syncedCount: pushResult.syncedCount, + removedCount: pushResult.removedCount, + importedCount: agendaResult.importedCount, + importedRemovedCount: agendaResult.importedRemovedCount, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Google Calendar sync failed"; + await ctx.runMutation(calendarInternal.markGoogleSyncResult, { + integrationId: integration._id, + lastError: message, + }); + throw error; + } +} + +export const connectGoogleCalendarWithCodeInternal = internalAction({ + args: { + code: v.string(), + codeVerifier: v.string(), + redirectUri: v.string(), + clientId: v.string(), + }, + returns: v.object({ + ok: v.boolean(), + connected: v.boolean(), + accountEmail: v.optional(v.string()), + }), + handler: async (ctx, args) => { + const currentUser = await ctx.runQuery(api.users.getCurrentUser, {}); + if (!currentUser || (currentUser.role !== "instructor" && currentUser.role !== "studio")) { + throw new ConvexError("Only instructors and studios can connect Google Calendar"); + } + + assertGoogleClientIdAllowed(args.clientId); + + const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, { + userId: currentUser._id, + })) as CalendarOwnerProfile | null; + if (!profile) { + throw new ConvexError("Calendar profile not found"); + } + + const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { + userId: currentUser._id, + })) as GoogleIntegrationRecord | null; + + const token = await exchangeGoogleAuthorizationCode({ + code: args.code, + codeVerifier: args.codeVerifier, + redirectUri: args.redirectUri, + clientId: args.clientId, + }); + + const refreshToken = token.refresh_token + ? encryptCalendarToken(token.refresh_token) + : existingIntegration?.refreshToken; + const accessToken = token.access_token; + if (!accessToken) { + throw new ConvexError("Google access token was missing from authorization response"); + } + const accountEmail = await fetchGoogleAccountEmail(accessToken); + + await ctx.runMutation(calendarInternal.upsertGoogleIntegration, { + userId: currentUser._id, + role: profile.role, + ...(profile.instructorId ? { instructorId: profile.instructorId } : {}), + ...(profile.studioId ? { studioId: profile.studioId } : {}), + accountEmail, + oauthClientId: args.clientId, + accessToken: encryptRequiredCalendarToken(accessToken), + refreshToken, + accessTokenExpiresAt: Date.now() + Math.max(60, token.expires_in ?? 3600) * 1000, + scopes: parseScopes(token.scope), + enableSync: true, + clearError: true, + }); + + await runGoogleCalendarSync(ctx, { + userId: currentUser._id, + requireConnected: true, + }); + + return { + ok: true, + connected: true, + ...omitUndefined({ accountEmail }), + }; + }, +}); + +export const syncMyGoogleCalendarEventsInternal = internalAction({ + args: { + startTime: v.optional(v.number()), + endTime: v.optional(v.number()), + limit: v.optional(v.number()), + }, + returns: v.object({ + ok: v.boolean(), + syncedCount: v.number(), + removedCount: v.number(), + importedCount: v.number(), + importedRemovedCount: v.number(), + }), + handler: async ( + ctx, + args, + ): Promise<{ + ok: boolean; + syncedCount: number; + removedCount: number; + importedCount: number; + importedRemovedCount: number; + }> => { + const currentUser = (await ctx.runQuery(api.users.getCurrentUser as any, {})) as + | { _id: Id<"users">; role: string } + | null; + if (!currentUser || (currentUser.role !== "instructor" && currentUser.role !== "studio")) { + throw new ConvexError("Only instructors and studios can sync Google Calendar"); + } + + return await runGoogleCalendarSync(ctx, { + userId: currentUser._id, + ...omitUndefined({ + startTime: args.startTime, + endTime: args.endTime, + limit: args.limit, + }), + requireConnected: true, + }); + }, +}); + +export const disconnectGoogleCalendarInternal = internalAction({ + args: {}, + returns: v.object({ + ok: v.boolean(), + deletedRemoteEvents: v.boolean(), + }), + handler: async (ctx) => { + const currentUser = await ctx.runQuery(api.users.getCurrentUser, {}); + if (!currentUser || (currentUser.role !== "instructor" && currentUser.role !== "studio")) { + throw new ConvexError("Only instructors and studios can disconnect Google Calendar"); + } + + const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { + userId: currentUser._id, + })) as GoogleIntegrationRecord | null; + if (!integration) { + await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, { + userId: currentUser._id, + }); + return { ok: true, deletedRemoteEvents: true }; + } + + let deletedRemoteEvents = true; + try { + const accessToken = await getGoogleAccessToken(ctx, integration, Date.now()); + const mappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, { + integrationId: integration._id, + })) as Array<{ providerEventId: string }>; + + for (const mapping of mappings) { + try { + await deleteGoogleEvent({ accessToken, providerEventId: mapping.providerEventId }); + } catch { + deletedRemoteEvents = false; + } + } + } catch { + deletedRemoteEvents = false; + } + + await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, { + userId: currentUser._id, + }); + + return { + ok: true, + deletedRemoteEvents, + }; + }, +}); + +export const syncGoogleCalendarForUserInternal = internalAction({ + args: { + userId: v.id("users"), + startTime: v.optional(v.number()), + endTime: v.optional(v.number()), + limit: v.optional(v.number()), + }, + returns: v.object({ + ok: v.boolean(), + syncedCount: v.number(), + removedCount: v.number(), + importedCount: v.number(), + importedRemovedCount: v.number(), + }), + handler: async (ctx, args) => { + return await runGoogleCalendarSync(ctx, { + userId: args.userId, + ...omitUndefined({ + startTime: args.startTime, + endTime: args.endTime, + limit: args.limit, + }), + requireConnected: false, + }); + }, +}); diff --git a/convex/jobs.ts b/convex/jobs.ts index f92244cd..4452126b 100644 --- a/convex/jobs.ts +++ b/convex/jobs.ts @@ -51,6 +51,19 @@ function getUniqueIdsInOrder(ids: ReadonlyArray) { return [...new Set(ids)]; } +async function scheduleGoogleCalendarSyncForUser( + ctx: MutationCtx, + userId: Id<"users"> | undefined, +) { + if (!userId) { + return; + } + + await ctx.scheduler.runAfter(0, internal.calendar.syncGoogleCalendarForUser, { + userId, + }); +} + async function loadLatestPaymentDetailsByJobId( ctx: QueryCtx, args: { @@ -543,6 +556,8 @@ export const postJob = mutation({ await ctx.scheduler.runAfter(expireDelay, internal.jobs.autoExpireUnfilledJob, { jobId }); } + await scheduleGoogleCalendarSyncForUser(ctx, studio.userId); + return { jobId }; }, }); @@ -1550,6 +1565,11 @@ export const runAcceptedApplicationReviewWorkflow = internalMutation({ event: "lesson_completed", }, ); + const acceptedInstructorProfile = profileById.get(String(acceptedApplication.instructorId)); + await Promise.all([ + scheduleGoogleCalendarSyncForUser(ctx, args.studioUserId), + scheduleGoogleCalendarSyncForUser(ctx, acceptedInstructorProfile?.userId), + ]); return { ok: true }; }, @@ -1705,6 +1725,8 @@ export const closeJobIfStillOpen = internalMutation({ } await ctx.db.patch("jobs", job._id, { status: "cancelled" }); + const studio = await ctx.db.get("studioProfiles", job.studioId); + await scheduleGoogleCalendarSyncForUser(ctx, studio?.userId); return { updated: true }; }, }); @@ -1732,6 +1754,7 @@ export const autoExpireUnfilledJob = internalMutation({ } await ctx.db.patch("jobs", job._id, { status: "cancelled" }); + await scheduleGoogleCalendarSyncForUser(ctx, studio?.userId); if (studio) { await enqueueUserNotification(ctx, { diff --git a/convex/lib/calendarCrypto.ts b/convex/lib/calendarCrypto.ts new file mode 100644 index 00000000..39fa0f9d --- /dev/null +++ b/convex/lib/calendarCrypto.ts @@ -0,0 +1,78 @@ +"use node"; + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { ConvexError } from "convex/values"; + +const CALENDAR_TOKEN_ENCRYPTION_PREFIX = "enc:v1:"; +const CALENDAR_TOKEN_ENCRYPTION_SECRET_ENV = "CALENDAR_TOKEN_ENCRYPTION_SECRET"; + +function getCalendarTokenEncryptionSecret(): string | undefined { + const secret = process.env[CALENDAR_TOKEN_ENCRYPTION_SECRET_ENV]?.trim(); + return secret ? secret : undefined; +} + +function deriveCalendarTokenKey(secret: string): Buffer { + return createHash("sha256").update(secret).digest(); +} + +export function isEncryptedCalendarToken(value: string | undefined): boolean { + return Boolean(value?.startsWith(CALENDAR_TOKEN_ENCRYPTION_PREFIX)); +} + +export function encryptCalendarToken(value: string | undefined): string | undefined { + if (!value) { + return value; + } + if (isEncryptedCalendarToken(value)) { + return value; + } + const secret = getCalendarTokenEncryptionSecret(); + if (!secret) { + return value; + } + + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", deriveCalendarTokenKey(secret), iv); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const payload = Buffer.concat([iv, authTag, ciphertext]).toString("base64url"); + return `${CALENDAR_TOKEN_ENCRYPTION_PREFIX}${payload}`; +} + +export function encryptRequiredCalendarToken(value: string): string { + return encryptCalendarToken(value) ?? value; +} + +export function decryptCalendarToken(value: string | undefined): string | undefined { + if (!value) { + return value; + } + if (!isEncryptedCalendarToken(value)) { + return value; + } + + const secret = getCalendarTokenEncryptionSecret(); + if (!secret) { + throw new ConvexError( + "Calendar token encryption secret is required to decrypt stored calendar credentials", + ); + } + + const encoded = value.slice(CALENDAR_TOKEN_ENCRYPTION_PREFIX.length); + const raw = Buffer.from(encoded, "base64url"); + if (raw.length <= 28) { + throw new ConvexError("Stored calendar token ciphertext is invalid"); + } + + const iv = raw.subarray(0, 12); + const authTag = raw.subarray(12, 28); + const ciphertext = raw.subarray(28); + + try { + const decipher = createDecipheriv("aes-256-gcm", deriveCalendarTokenKey(secret), iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + } catch { + throw new ConvexError("Stored calendar token could not be decrypted"); + } +} diff --git a/convex/lib/calendarShared.ts b/convex/lib/calendarShared.ts new file mode 100644 index 00000000..2bdd62e0 --- /dev/null +++ b/convex/lib/calendarShared.ts @@ -0,0 +1,145 @@ +import { omitUndefined } from "./validation"; + +export const GOOGLE_PROVIDER = "google" as const; +export const GOOGLE_EVENT_SOURCE_KEY = "queueSource"; +export const GOOGLE_EVENT_SOURCE_VALUE = "queue-job"; +export const GOOGLE_EVENT_EXTERNAL_ID_KEY = "queueExternalEventId"; + +export type CalendarOwnerRole = "instructor" | "studio"; + +export type TimelineRow = { + lessonId: string; + roleView: CalendarOwnerRole; + studioName: string; + instructorName?: string; + sport: string; + startTime: number; + endTime: number; + timeZone?: string; + status: "open" | "filled" | "cancelled" | "completed"; +}; + +export type GoogleCalendarEvent = { + id?: string; + etag?: string; + status?: string; + summary?: string; + location?: string; + htmlLink?: string; + updated?: string; + start?: { + date?: string; + dateTime?: string; + timeZone?: string; + }; + end?: { + date?: string; + dateTime?: string; + timeZone?: string; + }; + extendedProperties?: { + private?: Record; + }; +}; + +export type ImportedGoogleCalendarEvent = { + providerEventId: string; + title: string; + status: "confirmed" | "tentative" | "cancelled"; + startTime: number; + endTime: number; + isAllDay: boolean; + location?: string; + htmlLink?: string; + timeZone?: string; + providerUpdatedAt?: number; +}; + +export function buildGoogleEventBody(row: TimelineRow) { + const descriptionLines = [ + row.roleView === "studio" ? "Queue posted job" : "Queue accepted job", + `Studio: ${row.studioName}`, + ...(row.instructorName ? [`Instructor: ${row.instructorName}`] : []), + ]; + + return { + summary: `${row.sport} lesson`, + description: descriptionLines.join("\n"), + start: { + dateTime: new Date(row.startTime).toISOString(), + ...(row.timeZone ? { timeZone: row.timeZone } : {}), + }, + end: { + dateTime: new Date(row.endTime).toISOString(), + ...(row.timeZone ? { timeZone: row.timeZone } : {}), + }, + extendedProperties: { + private: { + [GOOGLE_EVENT_SOURCE_KEY]: GOOGLE_EVENT_SOURCE_VALUE, + [GOOGLE_EVENT_EXTERNAL_ID_KEY]: row.lessonId, + }, + }, + }; +} + +function parseGoogleEventTimestamp(value: string | undefined) { + if (!value) { + return undefined; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +export function normalizeImportedGoogleEvent( + event: GoogleCalendarEvent, +): ImportedGoogleCalendarEvent | null { + const providerEventId = event.id?.trim(); + if (!providerEventId) { + return null; + } + + const isAllDay = Boolean(event.start?.date && !event.start?.dateTime); + const startTime = parseGoogleEventTimestamp(event.start?.dateTime ?? event.start?.date); + const endTime = parseGoogleEventTimestamp(event.end?.dateTime ?? event.end?.date); + if (startTime === undefined || endTime === undefined || endTime <= startTime) { + return null; + } + + const status = + event.status === "cancelled" + ? "cancelled" + : event.status === "tentative" + ? "tentative" + : "confirmed"; + const title = event.summary?.trim() || "Google Calendar event"; + const providerUpdatedAt = parseGoogleEventTimestamp(event.updated); + + return { + providerEventId, + title, + status, + startTime, + endTime, + isAllDay, + ...omitUndefined({ + location: event.location?.trim() || undefined, + htmlLink: event.htmlLink?.trim() || undefined, + timeZone: event.start?.timeZone ?? event.end?.timeZone, + providerUpdatedAt, + }), + }; +} + +export function isQueueManagedGoogleEvent( + event: GoogleCalendarEvent, + mappedProviderEventIds: ReadonlySet, +) { + const providerEventId = event.id?.trim(); + if (providerEventId && mappedProviderEventIds.has(providerEventId)) { + return true; + } + + return ( + event.extendedProperties?.private?.[GOOGLE_EVENT_SOURCE_KEY] === GOOGLE_EVENT_SOURCE_VALUE + ); +} diff --git a/convex/onboarding.ts b/convex/onboarding.ts index 78554116..87673c9e 100644 --- a/convex/onboarding.ts +++ b/convex/onboarding.ts @@ -313,6 +313,8 @@ export const completeStudioOnboarding = mutation({ expoPushToken, }), notificationsEnabled, + calendarProvider: "none", + calendarSyncEnabled: false, createdAt: now, updatedAt: now, }), @@ -331,6 +333,9 @@ export const completeStudioOnboarding = mutation({ expoPushToken, }), notificationsEnabled, + calendarProvider: profileResolution.profile.calendarProvider ?? "none", + calendarSyncEnabled: profileResolution.profile.calendarSyncEnabled ?? false, + calendarConnectedAt: profileResolution.profile.calendarConnectedAt, updatedAt: now, }); diff --git a/convex/schema.ts b/convex/schema.ts index f6611ab3..afd576f4 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -91,7 +91,9 @@ export default defineSchema({ calendarIntegrations: defineTable({ userId: v.id("users"), - instructorId: v.id("instructorProfiles"), + role: v.union(v.literal("instructor"), v.literal("studio")), + instructorId: v.optional(v.id("instructorProfiles")), + studioId: v.optional(v.id("studioProfiles")), provider: v.union(v.literal("google"), v.literal("apple")), status: v.union(v.literal("connected"), v.literal("error"), v.literal("revoked")), accountEmail: v.optional(v.string()), @@ -100,6 +102,7 @@ export default defineSchema({ refreshToken: v.optional(v.string()), accessTokenExpiresAt: v.optional(v.number()), scopes: v.optional(v.array(v.string())), + agendaSyncToken: v.optional(v.string()), lastSyncedAt: v.optional(v.number()), lastError: v.optional(v.string()), createdAt: v.number(), @@ -122,6 +125,29 @@ export default defineSchema({ .index("by_integration_external_event", ["integrationId", "externalEventId"]) .index("by_integration_provider_event", ["integrationId", "providerEventId"]), + calendarExternalEvents: defineTable({ + integrationId: v.id("calendarIntegrations"), + providerEventId: v.string(), + title: v.string(), + status: v.union( + v.literal("confirmed"), + v.literal("tentative"), + v.literal("cancelled"), + ), + startTime: v.number(), + endTime: v.number(), + isAllDay: v.boolean(), + location: v.optional(v.string()), + htmlLink: v.optional(v.string()), + timeZone: v.optional(v.string()), + providerUpdatedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_integration", ["integrationId"]) + .index("by_integration_provider_event", ["integrationId", "providerEventId"]) + .index("by_integration_start_time", ["integrationId", "startTime"]), + instructorSports: defineTable({ instructorId: v.id("instructorProfiles"), sport: v.string(), @@ -174,6 +200,11 @@ export default defineSchema({ notificationsEnabled: v.optional(v.boolean()), logoStorageId: v.optional(v.id("_storage")), autoExpireMinutesBefore: v.optional(v.number()), + calendarProvider: v.optional( + v.union(v.literal("none"), v.literal("google"), v.literal("apple")), + ), + calendarSyncEnabled: v.optional(v.boolean()), + calendarConnectedAt: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), }) diff --git a/convex/users.ts b/convex/users.ts index 3865f521..8bff55fe 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -621,6 +621,9 @@ export const getMyStudioSettings = query({ socialLinks: v.optional(socialLinksValidator), autoExpireMinutesBefore: v.number(), sports: v.array(v.string()), + calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), + calendarSyncEnabled: v.boolean(), + calendarConnectedAt: v.optional(v.number()), }), v.null(), ), @@ -662,6 +665,46 @@ export const getMyStudioSettings = query({ hasExpoPushToken, autoExpireMinutesBefore: profile.autoExpireMinutesBefore ?? 30, sports, + calendarProvider: profile.calendarProvider ?? "none", + calendarSyncEnabled: profile.calendarSyncEnabled ?? false, + ...(profile.calendarConnectedAt !== undefined + ? { calendarConnectedAt: profile.calendarConnectedAt } + : {}), + }; + }, +}); + +export const updateMyStudioCalendarSettings = mutation({ + args: { + calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), + calendarSyncEnabled: v.boolean(), + }, + returns: v.object({ + ok: v.boolean(), + calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), + calendarSyncEnabled: v.boolean(), + }), + handler: async (ctx, args) => { + const user = await requireUserRole(ctx, ["studio"]); + const profile = await requireStudioProfileByUserId(ctx, user._id); + const now = Date.now(); + + const calendarProvider = args.calendarProvider; + const calendarSyncEnabled = calendarProvider !== "none" && args.calendarSyncEnabled; + const calendarConnectedAt = + calendarProvider === "none" ? undefined : (profile.calendarConnectedAt ?? now); + + await ctx.db.patch("studioProfiles", profile._id, { + calendarProvider, + calendarSyncEnabled, + ...(calendarConnectedAt !== undefined ? { calendarConnectedAt } : {}), + updatedAt: now, + }); + + return { + ok: true, + calendarProvider, + calendarSyncEnabled, }; }, }); diff --git a/convex/webhooks.ts b/convex/webhooks.ts index ffcf42ee..88b2c786 100644 --- a/convex/webhooks.ts +++ b/convex/webhooks.ts @@ -559,7 +559,6 @@ export const replayFailedIntegrationEvents = mutation({ }; }, }); - export const rapydWebhook = httpAction(async (ctx, req) => { if (req.method !== "POST") { return new Response("Method not allowed", { status: 405 }); diff --git a/global.css b/global.css deleted file mode 100644 index b5c61c95..00000000 --- a/global.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/metro.config.js b/metro.config.js index 50ceda26..37683420 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,6 +1,5 @@ const path = require("node:path"); const { getDefaultConfig } = require("expo/metro-config"); -const { withNativeWind } = require("nativewind/metro"); const config = getDefaultConfig(__dirname); @@ -45,4 +44,4 @@ config.transformer.getTransformOptions = async () => ({ }, }); -module.exports = withNativeWind(config, { input: "./global.css" }); +module.exports = config; diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts deleted file mode 100644 index a13e3136..00000000 --- a/nativewind-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/package.json b/package.json index 1474c4ba..2425ecaa 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "main": "expo-router/entry", "version": "1.0.0", "scripts": { - "postinstall": "node ./scripts/patches/patch-react-native-css-interop-safe-area.mjs && node ./scripts/patches/patch-expo-devtools-keep-awake.mjs && node ./scripts/patches/patch-expo-router-react-navigation-dedupe.mjs", + "dev": "bun run ./scripts/dev/dev.ts", + "postinstall": "node ./scripts/patches/patch-expo-devtools-keep-awake.mjs && node ./scripts/patches/patch-expo-router-react-navigation-dedupe.mjs", "start": "expo start", "reset-project": "node ./scripts/reset-project.js", "android": "node ./scripts/android/start-expo.mjs", @@ -57,7 +58,6 @@ "convex": "^1.32.0", "expo": "~55.0.5", "expo-auth-session": "~55.0.7", - "expo-blur": "~55.0.8", "expo-build-properties": "~55.0.9", "expo-calendar": "~55.0.9", "expo-constants": "~55.0.7", @@ -72,7 +72,6 @@ "expo-linking": "~55.0.7", "expo-localization": "~55.0.8", "expo-location": "~55.1.2", - "expo-navigation-bar": "~55.0.8", "expo-notifications": "~55.0.11", "expo-router": "~55.0.4", "expo-secure-store": "~55.0.8", @@ -84,7 +83,6 @@ "expo-web-browser": "~55.0.9", "geojson": "^0.5.0", "i18next": "^25.8.14", - "nativewind": "4.2.2", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^16.5.5", @@ -106,7 +104,6 @@ "@types/react": "~19.2.14", "babel-preset-expo": "~55.0.10", "knip": "^5.85.0", - "tailwindcss": "3.4.19", "typescript": "~5.9.3" }, "private": true diff --git a/scripts/android/install-release-device-linux.sh b/scripts/android/install-release-device-linux.sh index 532aab8e..5c0cf60f 100755 --- a/scripts/android/install-release-device-linux.sh +++ b/scripts/android/install-release-device-linux.sh @@ -83,6 +83,22 @@ quarantine_stale_node_module_android_outputs() { fi } +clean_stale_app_release_outputs() { + rm -rf \ + "$PROJECT_ROOT/android/app/build/intermediates/incremental/packageRelease" \ + "$PROJECT_ROOT/android/app/build/intermediates/incremental/assembleRelease" \ + "$PROJECT_ROOT/android/app/build/intermediates/apk/release" \ + "$PROJECT_ROOT/android/app/build/intermediates/packaged_manifests/release" \ + "$PROJECT_ROOT/android/app/build/outputs/apk/release" +} + +run_release_build() { + ( + cd android + NODE_ENV=production ./gradlew "${GRADLE_ARGS[@]}" + ) +} + ensure_android_project printf 'sdk.dir=%s\n' "$ANDROID_HOME" > "$PROJECT_ROOT/android/local.properties" @@ -185,10 +201,12 @@ if ! run_autolinking_probe; then fi fi -( - cd android - NODE_ENV=production ./gradlew "${GRADLE_ARGS[@]}" -) +if ! run_release_build; then + echo "Release build failed. Cleaning stale release packaging outputs and retrying once..." + clean_stale_app_release_outputs + quarantine_stale_node_module_android_outputs + run_release_build +fi APK_PATH="$PROJECT_ROOT/android/app/build/outputs/apk/release/app-release.apk" if [[ ! -f "$APK_PATH" ]]; then diff --git a/scripts/dev/dev.ts b/scripts/dev/dev.ts new file mode 100644 index 00000000..f935c4f8 --- /dev/null +++ b/scripts/dev/dev.ts @@ -0,0 +1,53 @@ +const bun = process.execPath; +const cwd = process.cwd(); + +function spawn(name: string, cmd: string[], stdin: "inherit" | "ignore") { + return { + name, + process: Bun.spawn(cmd, { + cwd, + stdin, + stdout: "inherit", + stderr: "inherit", + }), + }; +} + +const children = [ + spawn("convex", [bun, "run", "convex:dev"], "ignore"), + spawn("expo", [bun, "run", "start"], "inherit"), +]; + +let shuttingDown = false; + +function stopAll(signal: NodeJS.Signals = "SIGTERM") { + if (shuttingDown) { + return; + } + shuttingDown = true; + for (const child of children) { + if (!child.process.killed && child.process.exitCode === null) { + child.process.kill(signal); + } + } +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + stopAll(signal); + }); +} + +const result = await Promise.race( + children.map(({ name, process }) => + process.exited.then((code) => ({ + name, + code, + })), + ), +); + +stopAll(result.code === 0 ? "SIGTERM" : "SIGINT"); +await Promise.allSettled(children.map(({ process }) => process.exited)); + +process.exit(result.code); diff --git a/scripts/patches/patch-react-native-css-interop-safe-area.mjs b/scripts/patches/patch-react-native-css-interop-safe-area.mjs deleted file mode 100644 index 58ebe276..00000000 --- a/scripts/patches/patch-react-native-css-interop-safe-area.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(scriptDir, "..", ".."); -const targetFile = path.join( - projectRoot, - "node_modules", - "react-native-css-interop", - "dist", - "runtime", - "components.js", -); - -const deprecatedSafeAreaInteropPattern = - /\(0,\s*api_1\.cssInterop\)\(react_native_1\.SafeAreaView,\s*\{\s*className:\s*"style"\s*\}\);\r?\n/; - -if (!existsSync(targetFile)) { - console.warn("[postinstall] Skipping react-native-css-interop patch: target file not found."); - process.exit(0); -} - -const original = readFileSync(targetFile, "utf8"); - -if (!original.includes("react_native_1.SafeAreaView")) { - console.log( - "[postinstall] react-native-css-interop patch not needed: deprecated SafeAreaView interop is absent.", - ); - process.exit(0); -} - -const patched = original.replace(deprecatedSafeAreaInteropPattern, ""); - -if (patched === original) { - console.warn( - "[postinstall] Skipping react-native-css-interop patch: expected pattern not found.", - ); - process.exit(0); -} - -writeFileSync(targetFile, patched, "utf8"); -console.log("[postinstall] Patched react-native-css-interop SafeAreaView interop."); diff --git a/src/app/(app)/(instructor-tabs)/instructor/calendar/_layout.tsx b/src/app/(app)/(instructor-tabs)/instructor/calendar/_layout.tsx deleted file mode 100644 index 27b50e1d..00000000 --- a/src/app/(app)/(instructor-tabs)/instructor/calendar/_layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Slot } from "expo-router"; - -export default function InstructorCalendarLayout() { - return ; -} diff --git a/src/app/(app)/(instructor-tabs)/instructor/jobs/_layout.tsx b/src/app/(app)/(instructor-tabs)/instructor/jobs/_layout.tsx deleted file mode 100644 index 7dc691a3..00000000 --- a/src/app/(app)/(instructor-tabs)/instructor/jobs/_layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Slot } from "expo-router"; - -export default function InstructorJobsLayout() { - return ; -} diff --git a/src/app/(app)/(instructor-tabs)/instructor/map/_layout.tsx b/src/app/(app)/(instructor-tabs)/instructor/map/_layout.tsx deleted file mode 100644 index 487cee6e..00000000 --- a/src/app/(app)/(instructor-tabs)/instructor/map/_layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Slot } from "expo-router"; - -export default function InstructorMapLayout() { - return ; -} diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/calendar-settings.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/calendar-settings.tsx index 7f1f88ce..caec0e87 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/calendar-settings.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/calendar-settings.tsx @@ -1,6 +1,7 @@ import { useAction, useMutation, useQuery } from "convex/react"; import * as AuthSession from "expo-auth-session"; import { useRouter } from "expo-router"; +import * as WebBrowser from "expo-web-browser"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, StyleSheet, View } from "react-native"; @@ -49,6 +50,13 @@ type GoogleCalendarStatus = { lastError?: string | undefined; }; +type DisconnectGoogleCalendarResult = { + ok: boolean; + deletedRemoteEvents: boolean; +}; + +WebBrowser.maybeCompleteAuthSession(); + function resolveGoogleClientId() { if (Platform.OS === "ios") { return process.env.EXPO_PUBLIC_GOOGLE_CALENDAR_CLIENT_ID_IOS; @@ -75,9 +83,9 @@ export default function CalendarSettingsScreen() { ) as GoogleCalendarStatus | undefined; const saveSettings = useMutation(api.users.updateMyInstructorSettings); - const disconnectGoogleCalendar = useMutation(calendarApi.disconnectGoogleCalendar as any) as ( + const disconnectGoogleCalendar = useAction(calendarApi.disconnectGoogleCalendar as any) as ( args: Record, - ) => Promise; + ) => Promise; const exchangeGoogleCode = useAction(calendarApi.connectGoogleCalendarWithCode as any) as (args: { code: string; codeVerifier: string; @@ -143,6 +151,10 @@ export default function CalendarSettingsScreen() { setIsSaving(true); try { let nextSyncEnabled = syncEnabled; + const switchingAwayFromGoogle = + instructorSettings.calendarProvider === "google" && + hasGoogleConnection && + provider !== "google"; if (provider === "google" && !hasGoogleConnection) { nextSyncEnabled = false; @@ -159,6 +171,27 @@ export default function CalendarSettingsScreen() { setSyncEnabled(nextSyncEnabled); } + let disconnectResult: DisconnectGoogleCalendarResult | null = null; + if (switchingAwayFromGoogle) { + disconnectResult = await disconnectGoogleCalendar({}); + if (!disconnectResult.deletedRemoteEvents) { + Alert.alert( + t("profile.settings.calendar.disconnectCleanupWarningTitle", { + defaultValue: "Google disconnect completed with warnings", + }), + t("profile.settings.calendar.disconnectCleanupWarningBody", { + defaultValue: + "Queue removed the local connection, but some Queue-created Google events could not be deleted automatically.", + }), + ); + } + } + + if (provider === "none") { + router.back(); + return; + } + await saveSettings({ notificationsEnabled: instructorSettings.notificationsEnabled, sports: instructorSettings.sports, @@ -226,7 +259,18 @@ export default function CalendarSettingsScreen() { const onDisconnectGoogle = async () => { setIsDisconnectingGoogle(true); try { - await disconnectGoogleCalendar({}); + const result = await disconnectGoogleCalendar({}); + if (!result.deletedRemoteEvents) { + Alert.alert( + t("profile.settings.calendar.disconnectCleanupWarningTitle", { + defaultValue: "Google disconnect completed with warnings", + }), + t("profile.settings.calendar.disconnectCleanupWarningBody", { + defaultValue: + "Queue removed the local connection, but some Queue-created Google events could not be deleted automatically.", + }), + ); + } setProvider("none"); setSyncEnabled(false); } finally { @@ -242,10 +286,7 @@ export default function CalendarSettingsScreen() { : null; return ( - + diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx index 4ba5deac..d7451322 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx @@ -554,7 +554,6 @@ export default function IdentityVerificationScreen() { return ( diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx index d2565775..270c22cf 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx @@ -4,40 +4,33 @@ import { useQuery } from "convex/react"; import type { Href } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router"; import type { TFunction } from "i18next"; -import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - type StyleProp, - StyleSheet, - Switch, - useWindowDimensions, - View, - type ViewStyle, -} from "react-native"; +import { StyleSheet, Switch, View } from "react-native"; import type Animated from "react-native-reanimated"; import { useAnimatedRef, useScrollViewOffset } from "react-native-reanimated"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { LoadingScreen } from "@/components/loading-screen"; + import { getProfileHeroScrollTopPadding, ProfileDesktopHeroPanel, ProfileHeroSheet, } from "@/components/profile/profile-hero-sheet"; -import { ProfileReadinessStrip } from "@/components/profile/profile-readiness-strip"; +import { ProfileReadinessBanner } from "@/components/profile/profile-readiness-banner"; import { + ProfileSectionCard, ProfileSectionHeader, ProfileSettingRow, } from "@/components/profile/profile-settings-sections"; -import { IconSymbol } from "@/components/ui/icon-symbol"; -import type { BrandPalette } from "@/constants/brand"; import { useUser } from "@/contexts/user-context"; import { api } from "@/convex/_generated/api"; import { isSportType, toSportLabel } from "@/convex/constants"; import { useAppInsets } from "@/hooks/use-app-insets"; import { useAppLanguage } from "@/hooks/use-app-language"; import { useBrand } from "@/hooks/use-brand"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { useThemePreference } from "@/hooks/use-theme-preference"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; @@ -66,31 +59,17 @@ function getSportsSummary(sports: string[], t: TFunction) { return t("profile.settings.sports.selected", { count: sports.length }); } -function ProfileCardGroup({ - children, - palette, - style, -}: { - children: ReactNode; - palette: BrandPalette; - style?: StyleProp; -}) { - return ( - {children} - ); -} - export default function InstructorProfileScreen() { const { signOut } = useAuthActions(); const { currentUser } = useUser(); const { language, setLanguage } = useAppLanguage(); - const { safeTop } = useAppInsets(); const { preference, setPreference } = useThemePreference(); const { t, i18n } = useTranslation(); const palette = useBrand(); const router = useRouter(); const isFocused = useIsFocused(); - const { width } = useWindowDimensions(); + const { isDesktopWeb } = useLayoutBreakpoint(); + const { safeTop } = useAppInsets(); const { edit } = useLocalSearchParams<{ edit?: string }>(); const scrollRef = useAnimatedRef(); const scrollY = useScrollViewOffset(scrollRef); @@ -161,84 +140,78 @@ export default function InstructorProfileScreen() { : provider === "google" ? "Google" : "Apple"; - const isDesktopWeb = process.env.EXPO_OS === "web" && width >= 1180; - const socialCount = Object.keys(instructorSettings?.socialLinks ?? {}).length; const sportsCount = instructorSettings?.sports?.length ?? 0; + const socialCount = Object.keys(instructorSettings?.socialLinks ?? {}).length; const setupActions = [ !identityVerified ? { - label: "Verify identity", + label: t("profile.setup.verifyIdentity", { defaultValue: "Verify identity" }), onPress: () => router.push(INSTRUCTOR_IDENTITY_VERIFICATION_ROUTE as Href), + icon: "checkmark.circle.fill" as const, } : null, !bankConnected ? { - label: "Connect payouts", + label: t("profile.setup.connectPayouts", { defaultValue: "Connect payouts" }), onPress: () => router.push(INSTRUCTOR_PAYMENTS_ROUTE as Href), + icon: "sparkles" as const, } : null, sportsCount === 0 ? { - label: "Choose sports", + label: t("profile.setup.chooseSports", { defaultValue: "Choose sports" }), onPress: () => router.push(INSTRUCTOR_SPORTS_ROUTE as Href), + icon: "sparkles" as const, } : null, !provider || provider === "none" ? { - label: "Link calendar", + label: t("profile.setup.linkCalendar", { defaultValue: "Link calendar" }), onPress: () => router.push(INSTRUCTOR_CALENDAR_SETTINGS_ROUTE as Href), + icon: "calendar.badge.clock" as const, } : null, - ].filter((item): item is { label: string; onPress: () => void } => item !== null); + ].filter( + ( + item, + ): item is { + label: string; + onPress: () => void; + icon: "sparkles" | "checkmark.circle.fill" | "calendar.badge.clock"; + } => item !== null, + ); + const primarySetupAction = setupActions[0] ?? null; + const setupStatusLabel = setupActions.length === 0 - ? "Ready for bookings" - : `${String(setupActions.length)} setup moves left`; + ? t("profile.setup.statusReady", { defaultValue: "Ready to run jobs" }) + : t("profile.setup.statusPending", { + count: setupActions.length, + defaultValue: `${String(setupActions.length)} polish moves left`, + }); + const publicProfileSummary = instructorSettings?.bio?.trim() || (socialCount > 0 - ? `${String(socialCount)} public links are live and visible before a booking decision.` - : "Keep your photo, bio, and links sharp so studios can scan you fast."); - const primarySetupAction = setupActions[0] ?? null; - const readinessItems = [ - { - label: "Action queue", - value: setupActions.length === 0 ? "Profile ready" : `${String(setupActions.length)} open`, - caption: primarySetupAction?.label ?? "Identity, payouts, and scheduling are all lined up.", - accent: setupActions.length === 0 ? (palette.success as string) : (palette.warning as string), - ...(primarySetupAction ? { onPress: primarySetupAction.onPress } : {}), - }, - { - label: "Public profile", - value: sportsCount === 0 ? "Needs shape" : `${String(sportsCount)} sports`, - caption: socialCount > 0 ? `${String(socialCount)} links live` : "Photo, bio, and links", - onPress: handleRequestEdit, - }, - { - label: "Calendar", - value: calendarSummary, - caption: "Booking source", - onPress: () => router.push(INSTRUCTOR_CALENDAR_SETTINGS_ROUTE as Href), - }, - { - label: "Location", - value: locationSummary, - caption: "Match coverage", - onPress: () => router.push(INSTRUCTOR_LOCATION_ROUTE as Href), - }, - ]; + ? t("profile.settings.publicProfileActive", { + count: socialCount, + defaultValue: `${String(socialCount)} public links are live.`, + }) + : t("profile.settings.publicProfilePrompt", { + defaultValue: "Shape the identity people scan before they apply or accept.", + })); return ( {isDesktopWeb ? ( @@ -246,109 +219,155 @@ export default function InstructorProfileScreen() { + + - + router.push(INSTRUCTOR_SPORTS_ROUTE as Href)} palette={palette} + showDivider /> router.push(INSTRUCTOR_LOCATION_ROUTE as Href)} palette={palette} + showDivider /> router.push(INSTRUCTOR_CALENDAR_SETTINGS_ROUTE as Href)} palette={palette} - isLast /> - + - + {memberSince ? ( ) : null} - + - + void setLanguage(language === "en" ? "he" : "en")} palette={palette} + showDivider /> } /> - + - + router.push(INSTRUCTOR_PAYMENTS_ROUTE as Href)} palette={palette} - isLast /> - + - + void signOut()} palette={palette} tone="danger" - isLast - accessory={ - - } /> - + ) : ( - <> - - - - - - router.push(INSTRUCTOR_SPORTS_ROUTE as Href)} - palette={palette} - /> - router.push(INSTRUCTOR_LOCATION_ROUTE as Href)} - palette={palette} - /> - router.push(INSTRUCTOR_CALENDAR_SETTINGS_ROUTE as Href)} - palette={palette} - isLast - /> - - - - - - + + - + + - {memberSince ? ( + + + router.push(INSTRUCTOR_SPORTS_ROUTE as Href)} + palette={palette} + showDivider + /> + router.push(INSTRUCTOR_LOCATION_ROUTE as Href)} + palette={palette} + showDivider + /> router.push(INSTRUCTOR_CALENDAR_SETTINGS_ROUTE as Href)} palette={palette} - isLast /> - ) : null} - + - - - void setLanguage(language === "en" ? "he" : "en")} - palette={palette} - /> - setPreference(value ? "system" : "light")} - trackColor={{ - true: palette.primary as string, - false: palette.borderStrong as string, - }} - /> - } /> - setPreference(value ? "dark" : "light")} - trackColor={{ - true: palette.primary as string, - false: palette.borderStrong as string, - }} + + + + + {memberSince ? ( + - } - /> - + ) : null} + - - - router.push(INSTRUCTOR_PAYMENTS_ROUTE as Href)} + - - - - + + void setLanguage(language === "en" ? "he" : "en")} + palette={palette} + showDivider + /> + setPreference(value ? "system" : "light")} + trackColor={{ + true: palette.primary as string, + false: palette.borderStrong as string, + }} + /> + } + /> void signOut()} + title={t("profile.appearance.darkMode.title")} + icon="moon.fill" palette={palette} - tone="danger" - isLast accessory={ - + setPreference(value ? "dark" : "light")} + trackColor={{ + true: palette.primary as string, + false: palette.borderStrong as string, + }} + /> + } + /> + + + + + router.push(INSTRUCTOR_PAYMENTS_ROUTE as Href)} + palette={palette} /> - + + + + + void signOut()} + palette={palette} + tone="danger" + /> + + - + )} {isDesktopWeb ? null : ( + diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx index 647f5f4f..529603aa 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx @@ -163,6 +163,25 @@ export default function ProfilePaymentsScreen() { } }; + const confirmWithdrawToBank = () => { + Alert.alert( + t("profile.payments.withdrawConfirmTitle", { defaultValue: "Withdraw to bank?" }), + t("profile.payments.withdrawConfirmBody", { + defaultValue: "This will start a payout for your available balance.", + }), + [ + { text: t("common.cancel", { defaultValue: "Cancel" }), style: "cancel" }, + { + text: t("profile.payments.withdrawConfirmAction", { defaultValue: "Withdraw" }), + style: "default", + onPress: () => { + void withdrawToBank(); + }, + }, + ], + ); + }; + const startHostedBankOnboarding = async () => { setOnboardingBusy(true); setIsFinalizingOnboarding(false); @@ -210,7 +229,6 @@ export default function ProfilePaymentsScreen() { if (isFinalizingOnboarding) { return ( @@ -236,7 +254,6 @@ export default function ProfilePaymentsScreen() { if (showOnboardingSuccess) { return ( @@ -262,7 +279,6 @@ export default function ProfilePaymentsScreen() { return ( @@ -420,20 +436,7 @@ export default function ProfilePaymentsScreen() { overflow: "hidden", }} onPress={() => { - Alert.alert( - "Withdraw to bank?", - "This will start a payout withdrawal for your available balance.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Withdraw", - style: "destructive", - onPress: () => { - void withdrawToBank(); - }, - }, - ], - ); + confirmWithdrawToBank(); }} disabled={ withdrawBusy || diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/sports.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/sports.tsx index f94a4a0c..473abdc8 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/sports.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/sports.tsx @@ -3,7 +3,7 @@ import * as Haptics from "expo-haptics"; import { useRouter } from "expo-router"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, StyleSheet, View } from "react-native"; +import { StyleSheet, View } from "react-native"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { LoadingScreen } from "@/components/loading-screen"; import { SportsMultiSelect } from "@/components/profile/sports-multi-select"; @@ -30,6 +30,7 @@ export default function SportsScreen() { const [draft, setDraft] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); if (instructorSettings === undefined) { return ; @@ -64,6 +65,7 @@ export default function SportsScreen() { const onSave = async () => { if (!hasChanges || !draft) return; setIsSaving(true); + setErrorMessage(null); try { await saveSettings({ notificationsEnabled: instructorSettings.notificationsEnabled, @@ -85,9 +87,8 @@ export default function SportsScreen() { }); router.back(); } catch (error) { - Alert.alert( - t("profile.settings.errors.saveFailed", { defaultValue: "Failed to save settings." }), - error instanceof Error ? error.message : undefined, + setErrorMessage( + error instanceof Error ? error.message : t("profile.settings.errors.saveFailed"), ); } finally { setIsSaving(false); @@ -97,7 +98,6 @@ export default function SportsScreen() { return ( @@ -113,6 +113,11 @@ export default function SportsScreen() { title={t("profile.settings.sports.title")} emptyHint={t("profile.settings.sports.none")} /> + {errorMessage ? ( + + {errorMessage} + + ) : null} diff --git a/src/app/(app)/(studio-tabs)/studio/calendar/_layout.tsx b/src/app/(app)/(studio-tabs)/studio/calendar/_layout.tsx deleted file mode 100644 index ef66ec96..00000000 --- a/src/app/(app)/(studio-tabs)/studio/calendar/_layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Slot } from "expo-router"; - -export default function StudioCalendarLayout() { - return ; -} diff --git a/src/app/(app)/(studio-tabs)/studio/jobs/_layout.tsx b/src/app/(app)/(studio-tabs)/studio/jobs/_layout.tsx deleted file mode 100644 index bfeba18c..00000000 --- a/src/app/(app)/(studio-tabs)/studio/jobs/_layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Slot } from "expo-router"; - -export default function StudioJobsLayout() { - return ; -} diff --git a/src/app/(app)/(studio-tabs)/studio/profile/_layout.tsx b/src/app/(app)/(studio-tabs)/studio/profile/_layout.tsx index 6d05f18b..f5e58fbe 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/_layout.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/_layout.tsx @@ -18,6 +18,7 @@ export default function ProfileLayout() { }} > + diff --git a/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx b/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx new file mode 100644 index 00000000..85ca9353 --- /dev/null +++ b/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx @@ -0,0 +1,399 @@ +import { useAction, useMutation, useQuery } from "convex/react"; +import * as AuthSession from "expo-auth-session"; +import { useRouter } from "expo-router"; +import * as WebBrowser from "expo-web-browser"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Platform, StyleSheet, View } from "react-native"; + +import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; +import { LoadingScreen } from "@/components/loading-screen"; +import { + KitButton, + KitList, + KitListItem, + KitSegmentedToggle, + KitSwitchRow, +} from "@/components/ui/kit"; +import { BrandSpacing } from "@/constants/brand"; +import { useUser } from "@/contexts/user-context"; +import { api } from "@/convex/_generated/api"; +import { useBrand } from "@/hooks/use-brand"; +import { prepareDeviceCalendarSync } from "@/lib/device-calendar-sync"; + +const CALENDAR_PROVIDER_KEYS = { + none: "profile.settings.calendar.provider.none", + google: "profile.settings.calendar.provider.google", + apple: "profile.settings.calendar.provider.apple", +} as const; + +type CalendarProvider = keyof typeof CALENDAR_PROVIDER_KEYS; + +const GOOGLE_SCOPES = ["https://www.googleapis.com/auth/calendar.events", "openid", "email"]; + +const GOOGLE_DISCOVERY: AuthSession.DiscoveryDocument = { + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + revocationEndpoint: "https://oauth2.googleapis.com/revoke", +}; + +const calendarApi = (api as unknown as { calendar: Record }).calendar as { + getMyGoogleCalendarStatus: unknown; + disconnectGoogleCalendar: unknown; + connectGoogleCalendarWithCode: unknown; + syncMyGoogleCalendarEvents: unknown; +}; + +type StudioSettings = { + calendarProvider: CalendarProvider; + calendarSyncEnabled: boolean; + calendarConnectedAt?: number | undefined; +}; + +type GoogleCalendarStatus = { + connected: boolean; + accountEmail?: string | undefined; + lastError?: string | undefined; +}; + +type DisconnectGoogleCalendarResult = { + ok: boolean; + deletedRemoteEvents: boolean; +}; + +WebBrowser.maybeCompleteAuthSession(); + +function resolveGoogleClientId() { + if (Platform.OS === "ios") { + return process.env.EXPO_PUBLIC_GOOGLE_CALENDAR_CLIENT_ID_IOS; + } + if (Platform.OS === "android") { + return process.env.EXPO_PUBLIC_GOOGLE_CALENDAR_CLIENT_ID_ANDROID; + } + return process.env.EXPO_PUBLIC_GOOGLE_CALENDAR_CLIENT_ID_WEB; +} + +export default function StudioCalendarSettingsScreen() { + const { t, i18n } = useTranslation(); + const palette = useBrand(); + const router = useRouter(); + const { currentUser } = useUser(); + + const studioSettings = useQuery( + api.users.getMyStudioSettings, + currentUser?.role === "studio" ? {} : "skip", + ) as StudioSettings | null | undefined; + const googleStatus = useQuery( + calendarApi.getMyGoogleCalendarStatus as any, + currentUser?.role === "studio" ? {} : "skip", + ) as GoogleCalendarStatus | undefined; + + const saveSettings = useMutation(api.users.updateMyStudioCalendarSettings); + const disconnectGoogleCalendar = useAction(calendarApi.disconnectGoogleCalendar as any) as ( + args: Record, + ) => Promise; + const exchangeGoogleCode = useAction(calendarApi.connectGoogleCalendarWithCode as any) as (args: { + code: string; + codeVerifier: string; + redirectUri: string; + clientId: string; + }) => Promise; + const syncGoogleCalendar = useAction(calendarApi.syncMyGoogleCalendarEvents as any) as (args: { + startTime?: number; + endTime?: number; + limit?: number; + }) => Promise; + + const [provider, setProvider] = useState("none"); + const [syncEnabled, setSyncEnabled] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); + const [isSyncingGoogle, setIsSyncingGoogle] = useState(false); + const [isDisconnectingGoogle, setIsDisconnectingGoogle] = useState(false); + const [seeded, setSeeded] = useState(false); + + const googleClientId = resolveGoogleClientId(); + const redirectUri = + process.env.EXPO_PUBLIC_GOOGLE_CALENDAR_REDIRECT_URL ?? + AuthSession.makeRedirectUri({ scheme: "queue", path: "oauth/google-calendar" }); + + const [googleRequest, , promptGoogleAuth] = AuthSession.useAuthRequest( + { + clientId: googleClientId ?? "", + scopes: GOOGLE_SCOPES, + responseType: AuthSession.ResponseType.Code, + usePKCE: true, + redirectUri, + extraParams: { + access_type: "offline", + prompt: "consent", + include_granted_scopes: "true", + }, + }, + GOOGLE_DISCOVERY, + ); + + useEffect(() => { + if (studioSettings && !seeded) { + setProvider(studioSettings.calendarProvider ?? "none"); + setSyncEnabled(studioSettings.calendarSyncEnabled ?? false); + setSeeded(true); + } + }, [seeded, studioSettings]); + + if (studioSettings === undefined) { + return ; + } + if (studioSettings === null) { + return ; + } + + const hasGoogleConnection = Boolean(googleStatus?.connected); + const hasChanges = + provider !== (studioSettings.calendarProvider ?? "none") || + syncEnabled !== (studioSettings.calendarSyncEnabled ?? false); + + const onSave = async () => { + setIsSaving(true); + try { + let nextSyncEnabled = syncEnabled; + const switchingAwayFromGoogle = + studioSettings.calendarProvider === "google" && + hasGoogleConnection && + provider !== "google"; + + if (provider === "google" && !hasGoogleConnection) { + nextSyncEnabled = false; + } + + if (provider === "apple" && nextSyncEnabled) { + const preparation = await prepareDeviceCalendarSync(); + if (!preparation.ok) { + nextSyncEnabled = false; + } + } + + if (nextSyncEnabled !== syncEnabled) { + setSyncEnabled(nextSyncEnabled); + } + + if (switchingAwayFromGoogle) { + const disconnectResult = await disconnectGoogleCalendar({}); + if (!disconnectResult.deletedRemoteEvents) { + Alert.alert( + t("profile.settings.calendar.disconnectCleanupWarningTitle", { + defaultValue: "Google disconnect completed with warnings", + }), + t("profile.settings.calendar.disconnectCleanupWarningBody", { + defaultValue: + "Queue removed the local connection, but some Queue-created Google events could not be deleted automatically.", + }), + ); + } + } + + if (provider === "none") { + router.back(); + return; + } + + await saveSettings({ + calendarProvider: provider, + calendarSyncEnabled: nextSyncEnabled, + }); + router.back(); + } catch (error) { + Alert.alert( + t("profile.settings.errors.saveFailed", { defaultValue: "Failed to save settings." }), + error instanceof Error ? error.message : undefined, + ); + } finally { + setIsSaving(false); + } + }; + + const onConnectGoogle = async () => { + if (!googleClientId || !googleRequest?.codeVerifier) { + return; + } + + setIsConnectingGoogle(true); + try { + const result = await promptGoogleAuth(); + if (result.type !== "success" || !result.params.code) { + return; + } + + await exchangeGoogleCode({ + code: result.params.code, + codeVerifier: googleRequest.codeVerifier, + redirectUri, + clientId: googleClientId, + }); + + setProvider("google"); + setSyncEnabled(true); + } finally { + setIsConnectingGoogle(false); + } + }; + + const onSyncGoogleNow = async () => { + setIsSyncingGoogle(true); + try { + await syncGoogleCalendar({}); + } finally { + setIsSyncingGoogle(false); + } + }; + + const onDisconnectGoogle = async () => { + setIsDisconnectingGoogle(true); + try { + const result = await disconnectGoogleCalendar({}); + if (!result.deletedRemoteEvents) { + Alert.alert( + t("profile.settings.calendar.disconnectCleanupWarningTitle", { + defaultValue: "Google disconnect completed with warnings", + }), + t("profile.settings.calendar.disconnectCleanupWarningBody", { + defaultValue: + "Queue removed the local connection, but some Queue-created Google events could not be deleted automatically.", + }), + ); + } + setProvider("none"); + setSyncEnabled(false); + } finally { + setIsDisconnectingGoogle(false); + } + }; + + const connectedDate = studioSettings.calendarConnectedAt + ? new Date(studioSettings.calendarConnectedAt).toLocaleDateString( + i18n.resolvedLanguage ?? "en", + { month: "short", day: "numeric", year: "numeric" }, + ) + : null; + + return ( + + + + + + value={provider} + onChange={(next) => { + setProvider(next); + if (next === "none") setSyncEnabled(false); + }} + options={(Object.keys(CALENDAR_PROVIDER_KEYS) as CalendarProvider[]).map((key) => ({ + value: key, + label: t(CALENDAR_PROVIDER_KEYS[key]), + }))} + /> + + + + + + + + {provider === "google" ? ( + + ) : null} + {provider === "apple" ? ( + + ) : null} + {provider === "google" && googleStatus?.lastError ? ( + + ) : null} + {connectedDate ? ( + + ) : null} + + + + + {provider === "google" ? ( + + {!hasGoogleConnection ? ( + { + void onConnectGoogle(); + }} + disabled={isConnectingGoogle || !googleClientId || !googleRequest} + /> + ) : ( + <> + { + void onSyncGoogleNow(); + }} + disabled={isSyncingGoogle} + /> + { + void onDisconnectGoogle(); + }} + disabled={isDisconnectingGoogle} + /> + + )} + + ) : null} + + { + void onSave(); + }} + disabled={isSaving || !hasChanges} + /> + router.back()} /> + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, +}); diff --git a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx index 7ae2d78b..30f3326d 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -4,17 +4,9 @@ import { useQuery } from "convex/react"; import type { Href } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router"; import type { TFunction } from "i18next"; -import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - type StyleProp, - StyleSheet, - Switch, - useWindowDimensions, - View, - type ViewStyle, -} from "react-native"; +import { StyleSheet, Switch, View } from "react-native"; import type Animated from "react-native-reanimated"; import { useAnimatedRef, useScrollViewOffset } from "react-native-reanimated"; @@ -25,19 +17,19 @@ import { ProfileDesktopHeroPanel, ProfileHeroSheet, } from "@/components/profile/profile-hero-sheet"; -import { ProfileReadinessStrip } from "@/components/profile/profile-readiness-strip"; +import { ProfileReadinessBanner } from "@/components/profile/profile-readiness-banner"; import { + ProfileSectionCard, ProfileSectionHeader, ProfileSettingRow, } from "@/components/profile/profile-settings-sections"; -import { IconSymbol } from "@/components/ui/icon-symbol"; -import type { BrandPalette } from "@/constants/brand"; import { useUser } from "@/contexts/user-context"; import { api } from "@/convex/_generated/api"; import { isSportType, toSportLabel } from "@/convex/constants"; import { useAppInsets } from "@/hooks/use-app-insets"; import { useAppLanguage } from "@/hooks/use-app-language"; import { useBrand } from "@/hooks/use-brand"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { useThemePreference } from "@/hooks/use-theme-preference"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; @@ -48,6 +40,7 @@ const ROLE_TRANSLATION_KEYS = { admin: "profile.roles.admin", } as const; const STUDIO_PROFILE_ROUTE = buildRoleTabRoute("studio", ROLE_TAB_ROUTE_NAMES.profile); +const STUDIO_CALENDAR_SETTINGS_ROUTE = `${STUDIO_PROFILE_ROUTE}/calendar-settings` as const; const STUDIO_PAYMENTS_ROUTE = `${STUDIO_PROFILE_ROUTE}/payments` as const; const STUDIO_EDIT_ROUTE = `${STUDIO_PROFILE_ROUTE}/edit` as const; @@ -61,31 +54,17 @@ function getSportsSummary(sports: string[], t: TFunction) { return t("profile.settings.sports.selected", { count: sports.length }); } -function ProfileCardGroup({ - children, - palette, - style, -}: { - children: ReactNode; - palette: BrandPalette; - style?: StyleProp; -}) { - return ( - {children} - ); -} - export default function StudioProfileScreen() { const { signOut } = useAuthActions(); const { currentUser } = useUser(); const { language, setLanguage } = useAppLanguage(); - const { safeTop } = useAppInsets(); const { preference, setPreference } = useThemePreference(); const { t, i18n } = useTranslation(); const palette = useBrand(); const router = useRouter(); const isFocused = useIsFocused(); - const { width } = useWindowDimensions(); + const { isDesktopWeb } = useLayoutBreakpoint(); + const { safeTop } = useAppInsets(); const { edit } = useLocalSearchParams<{ edit?: string }>(); const scrollRef = useAnimatedRef(); const scrollY = useScrollViewOffset(scrollRef); @@ -134,15 +113,41 @@ export default function StudioProfileScreen() { : null; const sportsSummary = getSportsSummary(studioSettings?.sports ?? [], t); - const isDesktopWeb = process.env.EXPO_OS === "web" && width >= 1180; + const provider = studioSettings?.calendarProvider; + const calendarSummary = + !provider || provider === "none" + ? t("profile.settings.calendar.provider.none") + : provider === "google" + ? "Google" + : "Apple"; const socialCount = Object.keys(studioSettings?.socialLinks ?? {}).length; const sportsCount = studioSettings?.sports?.length ?? 0; const setupActions = [ - !studioSettings?.address ? { label: "Add studio details", onPress: handleRequestEdit } : null, - !studioSettings?.zone ? { label: "Set coverage zone", onPress: handleRequestEdit } : null, - sportsCount === 0 ? { label: "Pick sports", onPress: handleRequestEdit } : null, - socialCount === 0 ? { label: "Add contact links", onPress: handleRequestEdit } : null, - ].filter((item): item is { label: string; onPress: () => void } => item !== null); + !studioSettings?.address + ? { label: "Add studio details", onPress: handleRequestEdit, icon: "sparkles" as const } + : null, + !studioSettings?.zone + ? { + label: "Set coverage zone", + onPress: handleRequestEdit, + icon: "mappin.and.ellipse" as const, + } + : null, + sportsCount === 0 + ? { label: "Pick sports", onPress: handleRequestEdit, icon: "sparkles" as const } + : null, + socialCount === 0 + ? { label: "Add contact links", onPress: handleRequestEdit, icon: "sparkles" as const } + : null, + ].filter( + ( + item, + ): item is { + label: string; + onPress: () => void; + icon: "sparkles" | "mappin.and.ellipse"; + } => item !== null, + ); const setupStatusLabel = setupActions.length === 0 ? "Ready to run jobs" @@ -153,43 +158,17 @@ export default function StudioProfileScreen() { ? `${String(socialCount)} public links are live for applicants and instructors.` : "Shape the studio identity people scan before they apply or accept."); const primarySetupAction = setupActions[0] ?? null; - const readinessItems = [ - { - label: "Action queue", - value: setupActions.length === 0 ? "Profile ready" : `${String(setupActions.length)} open`, - caption: primarySetupAction?.label ?? "Coverage, profile, and defaults are lined up.", - accent: setupActions.length === 0 ? (palette.success as string) : (palette.warning as string), - ...(primarySetupAction ? { onPress: primarySetupAction.onPress } : {}), - }, - { - label: "Public profile", - value: sportsCount === 0 ? "Needs shape" : `${String(sportsCount)} sports`, - caption: socialCount > 0 ? `${String(socialCount)} links live` : "Photo, bio, and links", - onPress: handleRequestEdit, - }, - { - label: "Coverage", - value: studioSettings?.zone ?? "Unset", - caption: studioSettings?.address ?? "Add your studio address and operating area", - onPress: handleRequestEdit, - }, - { - label: "Defaults", - value: `${String(studioSettings?.autoExpireMinutesBefore ?? 30)} min`, - caption: "Auto-expire before start", - }, - ]; return ( {isDesktopWeb ? ( @@ -205,48 +184,60 @@ export default function StudioProfileScreen() { metaLabel={memberSince ? `Member since ${memberSince}` : sportsSummary} primaryAction={{ label: "Edit profile", onPress: handleRequestEdit }} {...(primarySetupAction ? { secondaryAction: primarySetupAction } : {})} - highlights={readinessItems} /> + + - + - + @@ -256,32 +247,37 @@ export default function StudioProfileScreen() { palette={palette} flush /> - + {memberSince ? ( ) : null} - + - + void setLanguage(language === "en" ? "he" : "en")} palette={palette} + showDivider /> } /> - + + + + + router.push(STUDIO_CALENDAR_SETTINGS_ROUTE as Href)} + palette={palette} + /> + - + router.push(STUDIO_PAYMENTS_ROUTE as Href)} palette={palette} - isLast /> - + - + void signOut()} palette={palette} tone="danger" - isLast - accessory={ - - } /> - + ) : ( - <> - - - - - - - + + - + + - + + + + + + - - - - - - {memberSince ? ( + + + - ) : null} - + {memberSince ? ( + + ) : null} + - - - void setLanguage(language === "en" ? "he" : "en")} - palette={palette} - /> - setPreference(value ? "system" : "light")} - trackColor={{ - true: palette.primary as string, - false: palette.borderStrong as string, - }} - /> - } /> - + void setLanguage(language === "en" ? "he" : "en")} + palette={palette} + showDivider + /> + setPreference(value ? "system" : "light")} + trackColor={{ + true: palette.primary as string, + false: palette.borderStrong as string, + }} + /> + } + /> + setPreference(value ? "dark" : "light")} + trackColor={{ + true: palette.primary as string, + false: palette.borderStrong as string, + }} + /> + } + /> + + + setPreference(value ? "dark" : "light")} - trackColor={{ - true: palette.primary as string, - false: palette.borderStrong as string, - }} - /> - } /> - + + router.push(STUDIO_CALENDAR_SETTINGS_ROUTE as Href)} + palette={palette} + /> + - - - router.push(STUDIO_PAYMENTS_ROUTE as Href)} + - - - - + void signOut()} + title="Payments & payouts" + subtitle="Open payout settings and manage destination details." + icon="creditcard.fill" + onPress={() => router.push(STUDIO_PAYMENTS_ROUTE as Href)} palette={palette} - tone="danger" - isLast - accessory={ - - } /> - + + + + + void signOut()} + palette={palette} + tone="danger" + /> + + - + )} @@ -534,10 +582,6 @@ const styles = StyleSheet.create({ screen: { flex: 1, }, - cardGroup: { - gap: 10, - marginHorizontal: 24, - }, desktopShell: { flexDirection: "row", alignItems: "flex-start", @@ -562,4 +606,10 @@ const styles = StyleSheet.create({ desktopCardGroup: { marginHorizontal: 0, }, + mobileContentPadding: { + paddingHorizontal: 24, + }, + mobileSectionsContainer: { + marginHorizontal: -24, + }, }); diff --git a/src/app/(app)/(studio-tabs)/studio/profile/payments.tsx b/src/app/(app)/(studio-tabs)/studio/profile/payments.tsx index 52cee378..14a27743 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/payments.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/payments.tsx @@ -51,7 +51,10 @@ export default function ProfilePaymentsScreen() { return ; } - const rows = paymentRows ?? []; + type PaymentListRow = NonNullable[number]; + type PaymentTimelineEvent = NonNullable["timeline"][number]; + + const rows = (paymentRows ?? []) as PaymentListRow[]; const role = currentUser.role as "studio" | "instructor"; const failedCount = rows.filter((row) => row.payment.status === "failed").length; const processedCount = rows.filter((row) => @@ -62,7 +65,6 @@ export default function ProfilePaymentsScreen() { return ( @@ -234,7 +236,7 @@ export default function ProfilePaymentsScreen() { ) : ( - selectedPaymentDetail.timeline.map((event) => ( + selectedPaymentDetail.timeline.map((event: PaymentTimelineEvent) => ( new Animated.Value(0), []); - const currentThemeKey = `${resolvedScheme}:${stylePreference}`; + const currentThemeKey = resolvedScheme; const [previousThemeKey, setPreviousThemeKey] = useState(currentThemeKey); const [previousBackgroundColor, setPreviousBackgroundColor] = useState(palette.appBg as string); @@ -147,9 +146,6 @@ function RootLayoutContent() { const navigationTheme = useMemo(() => { const base = resolvedScheme === "dark" ? DarkTheme : DefaultTheme; - if (stylePreference === "native") { - return base; - } return { ...base, colors: { @@ -170,7 +166,6 @@ function RootLayoutContent() { palette.surface, palette.text, resolvedScheme, - stylePreference, ]); useStartupPerfMetrics(); @@ -185,10 +180,15 @@ function RootLayoutContent() { ); } - const fallbackBackgroundColor = palette.appBg; + const fallbackBackgroundColor = + topInsetTone === "sheet" + ? palette.surfaceAlt + : topInsetTone === "card" + ? palette.surface + : topInsetTone === "transparent" + ? "transparent" + : palette.appBg; const statusInsetColor = topInsetBackgroundColor ?? fallbackBackgroundColor; - const statusBarBackgroundColor = - typeof statusInsetColor === "string" ? statusInsetColor : undefined; return ( @@ -216,12 +216,7 @@ function RootLayoutContent() { - + + {t("modal.title")} {t("modal.goHome")} - + ); } diff --git a/src/components/calendar/calendar-tab-screen.tsx b/src/components/calendar/calendar-tab-screen.tsx index cac31126..988ec131 100644 --- a/src/components/calendar/calendar-tab-screen.tsx +++ b/src/components/calendar/calendar-tab-screen.tsx @@ -1,95 +1,174 @@ import DateTimePicker from "@react-native-community/datetimepicker"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { FlashList } from "@shopify/flash-list"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { + I18nManager, + type LayoutChangeEvent, Platform, - SectionList, - type SectionListRenderItemInfo, + StyleSheet, Text, useWindowDimensions, View, } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { FadeInDown, FadeInUp, runOnJS } from "react-native-reanimated"; -import { TabScreenRoot } from "@/components/layout/tab-screen-root"; +import Animated, { + FadeInDown, + FadeInUp, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from "react-native-reanimated"; +import { ScreenScaffold } from "@/components/layout/screen-scaffold"; +import { TopSheetSurface } from "@/components/layout/top-sheet-surface"; import { LoadingScreen } from "@/components/loading-screen"; import { AppSymbol } from "@/components/ui/app-symbol"; -import { KitButton, KitPressable } from "@/components/ui/kit"; +import { KitButton, KitPressable, KitSegmentedToggle } from "@/components/ui/kit"; import { triggerSelectionHaptic } from "@/components/ui/kit/native-interaction"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useAppInsets } from "@/hooks/use-app-insets"; import { useBrand } from "@/hooks/use-brand"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { formatTime } from "@/lib/jobs-utils"; -import { CalendarWebBoard } from "./calendar-web-board"; import { - type AgendaItem, - type AgendaSection, - compareDayKey, - dayKeyToTimestamp, - type TimelineRow, - toDayKey, + type CalendarViewMode, + type TimelineListItem, useCalendarTabController, } from "./use-calendar-tab-controller"; -const WEEK_SWIPE_THRESHOLD = 42; +// ─── Constants ─────────────────────────────────────────────────────────────── -function formatMonthLabel(dayKey: string, locale: string) { - return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { - month: "long", - year: "numeric", - }); +const DAY_MS = 24 * 60 * 60 * 1000; + +const RAIL_LEFT = 24; +const RAIL_DOT_DAY = 10; +const RAIL_DOT_LESSON = 6; +const SWIPE_THRESHOLD = 50; +const WEEK_RAIL_SPRING = { + damping: 24, + stiffness: 220, + mass: 0.7, + overshootClamping: true, +}; + +// ─── Date helpers ──────────────────────────────────────────────────────────── + +function toDayKey(timestamp: number) { + const d = new Date(timestamp); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } -function formatSelectedDayLabel(dayKey: string, locale: string) { +function dayKeyToTimestamp(dayKey: string) { + const [y, m, d] = dayKey.split("-").map(Number) as [number, number, number]; + return new Date(y, m - 1, d).getTime(); +} + +function addDays(dayKey: string, delta: number) { + return toDayKey(dayKeyToTimestamp(dayKey) + delta * DAY_MS); +} + +function compareDayKey(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0; +} + +function getWeekStart(dayKey: string) { + const ts = dayKeyToTimestamp(dayKey); + const d = new Date(ts); + const dow = d.getDay(); // 0=Sun + const mondayOffset = dow === 0 ? -6 : 1 - dow; + return toDayKey(ts + mondayOffset * DAY_MS); +} + +function getWeekDays(weekStartKey: string): string[] { + return Array.from({ length: 7 }, (_, i) => addDays(weekStartKey, i)); +} + +function getMonthWeeks(monthDayKey: string): string[][] { + const ts = dayKeyToTimestamp(monthDayKey); + const d = new Date(ts); + const year = d.getFullYear(); + const month = d.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const weeks: string[][] = []; + let cursor = getWeekStart(toDayKey(firstDay.getTime())); + const lastDayKey = toDayKey(lastDay.getTime()); + while (true) { + const week = getWeekDays(cursor); + weeks.push(week); + // Stop if the week's last day is past the month's last day + if (compareDayKey(week[6]!, lastDayKey) >= 0) break; + cursor = addDays(cursor, 7); + } + return weeks; +} + +function getMonthStart(dayKey: string) { + const ts = dayKeyToTimestamp(dayKey); + const d = new Date(ts); + return toDayKey(new Date(d.getFullYear(), d.getMonth(), 1).getTime()); +} + +// Format: "January 15" (month + day number) +function formatDayHeading(dayKey: string, locale: string) { return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { - weekday: "long", month: "long", day: "numeric", }); } -function formatSectionTitle(dayKey: string, locale: string) { +// Format: "Monday" +function formatDaySubtitle(dayKey: string, locale: string) { return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { - month: "long", - day: "numeric", + weekday: "long", }); } -function formatSectionSubtitle(dayKey: string, locale: string) { +function formatMonthYear(dayKey: string, locale: string) { return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { - weekday: "long", + month: "long", + year: "numeric", }); } -function formatWeekdayLabel(dayKey: string, locale: string) { - const label = new Date(dayKeyToTimestamp(dayKey)) - .toLocaleDateString(locale, { weekday: "short" }) - .replace(/\./g, "") - .replace(/\s+/g, ""); - const glyphs = Array.from(label); - if (glyphs.length <= 2) { - return label.toUpperCase(); - } - return glyphs.slice(0, 2).join("").toUpperCase(); +function formatWeekdayLetter(dayKey: string, locale: string) { + return new Date(dayKeyToTimestamp(dayKey)) + .toLocaleDateString(locale, { weekday: "narrow" }) + .charAt(0) + .toUpperCase(); } function formatDayNumber(dayKey: string) { return String(new Date(dayKeyToTimestamp(dayKey)).getDate()); } -function isToday(dayKey: string, todayKey: string) { - return compareDayKey(dayKey, todayKey) === 0; +function isSameMonth(dayKeyA: string, dayKeyB: string) { + return dayKeyA.substring(0, 7) === dayKeyB.substring(0, 7); +} + +function formatSelectedDayLabel(dayKey: string, locale: string) { + return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { + weekday: "long", + month: "long", + day: "numeric", + }); } function hashSport(sport: string) { - let hash = 0; - for (let index = 0; index < sport.length; index += 1) { - hash = sport.charCodeAt(index) + ((hash << 5) - hash); + let h = 0; + for (let i = 0; i < sport.length; i++) { + h = sport.charCodeAt(i) + ((h << 5) - h); } - return Math.abs(hash); + return Math.abs(h); } -// ─── WeekRail ──────────────────────────────────────────────────────────────── +// ─── WeekStrip (live-sliding, dynamic height) ──────────────────────────────── + +function shiftDayKey(dayKey: string, deltaDays: number) { + return toDayKey(dayKeyToTimestamp(dayKey) + deltaDays * DAY_MS); +} function WeekRail({ locale, @@ -109,6 +188,14 @@ function WeekRail({ onChangeWeek: (deltaWeeks: number) => void; }) { const palette = useBrand(); + const translateX = useSharedValue(-1); + const railWidth = useSharedValue(1); + const isRtl = I18nManager.isRTL; + const previousWeekDays = useMemo( + () => weekDays.map((dayKey) => shiftDayKey(dayKey, -7)), + [weekDays], + ); + const nextWeekDays = useMemo(() => weekDays.map((dayKey) => shiftDayKey(dayKey, 7)), [weekDays]); const handleSwipeWeek = useCallback( (deltaWeeks: number) => { @@ -125,40 +212,77 @@ function WeekRail({ [onSelectDay], ); + const onRailLayout = useCallback( + (event: LayoutChangeEvent) => { + const nextWidth = Math.max(1, event.nativeEvent.layout.width); + railWidth.value = nextWidth; + translateX.value = -nextWidth; + }, + [railWidth, translateX], + ); + const railGesture = useMemo( () => Gesture.Pan() .activeOffsetX([-20, 20]) .failOffsetY([-12, 12]) + .onUpdate((event) => { + const width = railWidth.value || 1; + const nextX = -width + event.translationX; + translateX.value = Math.max(-width * 2, Math.min(0, nextX)); + }) .onEnd((event) => { - if (event.translationX <= -WEEK_SWIPE_THRESHOLD || event.velocityX <= -650) { - runOnJS(handleSwipeWeek)(1); + const width = railWidth.value || 1; + const shouldAdvance = + event.translationX <= -Math.max(SWIPE_THRESHOLD, width * 0.2) || + event.velocityX <= -650; + const shouldRewind = + event.translationX >= Math.max(SWIPE_THRESHOLD, width * 0.2) || event.velocityX >= 650; + + if (shouldAdvance) { + translateX.value = withSpring(-width * 2, WEEK_RAIL_SPRING, (finished) => { + if (!finished) return; + translateX.value = -width; + runOnJS(handleSwipeWeek)(1); + }); return; } - if (event.translationX >= WEEK_SWIPE_THRESHOLD || event.velocityX >= 650) { - runOnJS(handleSwipeWeek)(-1); + + if (shouldRewind) { + translateX.value = withSpring(0, WEEK_RAIL_SPRING, (finished) => { + if (!finished) return; + translateX.value = -width; + runOnJS(handleSwipeWeek)(-1); + }); + return; } + + translateX.value = withSpring(-width, WEEK_RAIL_SPRING); }), - [handleSwipeWeek], + [handleSwipeWeek, railWidth, translateX], ); - return ( - - - {weekDays.map((dayKey, index) => { + const railTrackStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const renderWeekPage = useCallback( + (days: string[], pageKey: string) => ( + + {days.map((dayKey) => { const selected = dayKey === selectedDay; const lessonCount = lessonCountByDay.get(dayKey) ?? 0; - const today = isToday(dayKey, todayKey); + const today = dayKey === todayKey; return ( - + - {/* Today dot indicator at top */} {today && !selected ? ( ) : null} - - {formatWeekdayLabel(dayKey, locale)} + {formatWeekdayLetter(dayKey, locale)} - - {formatDayNumber(dayKey)} - - - {/* Lesson dot/count */} + + {formatDayNumber(dayKey)} + + + ), + [handleSelectDay, lessonCountByDay, locale, palette, selectedDay, todayKey], + ); + + return ( + + + + {renderWeekPage(previousWeekDays, "previous")} + {renderWeekPage(weekDays, "current")} + {renderWeekPage(nextWeekDays, "next")} + + ); } -// ─── AgendaLessonCard ───────────────────────────────────────────────────────── +const WEEK_ROW_HEIGHT = 46; // height of one row of day cells +const DRAG_HANDLE_HEIGHT = 16; // drag bar area +const HEADER_HEIGHT = 44; // month label row +const LABELS_HEIGHT = 20; // weekday letter labels -function AgendaLessonCard({ +export function WeekStrip({ + selectedDay, + onDayPress, + onWeekChange, + onMonthPress, + onTodayPress, + lessonCountByDay, locale, - row, - index, + todayLabel, + monthButtonLabel, + dragHandleLabel, }: { + selectedDay: string; + onDayPress: (dayKey: string) => void; + onWeekChange: (delta: number) => void; + onMonthPress: () => void; + onTodayPress: () => void; + lessonCountByDay: Map; locale: string; - row: TimelineRow; - index: number; + todayLabel: string; + monthButtonLabel: string; + dragHandleLabel: string; }) { const palette = useBrand(); - const swatches = palette.calendar.eventSwatches; - const swatch = swatches[hashSport(row.sport) % Math.max(swatches.length, 1)] ?? undefined; - const accent = (swatch?.background as string) ?? (palette.primary as string); - const counterpart = - row.roleView === "instructor" - ? row.studioName - : (row.instructorName ?? "Unassigned instructor"); - - const lifecycleTone = - row.lifecycle === "live" - ? { fg: palette.success as string, bg: palette.successSubtle as string, label: "Live now" } - : row.lifecycle === "upcoming" - ? { fg: palette.primary as string, bg: palette.primarySubtle as string, label: "Upcoming" } - : row.lifecycle === "cancelled" - ? { fg: palette.danger as string, bg: palette.dangerSubtle as string, label: "Cancelled" } - : { fg: palette.textMuted as string, bg: palette.surfaceAlt as string, label: "Past" }; + const { width: screenWidth } = useWindowDimensions(); + const todayKey = useMemo(() => toDayKey(Date.now()), []); + const selectedWeekStart = useMemo(() => getWeekStart(selectedDay), [selectedDay]); + const [displayedWeekStart, setDisplayedWeekStart] = useState(selectedWeekStart); + const displayedWeekStartRef = useRef(selectedWeekStart); + const [displayedSelectedDay, setDisplayedSelectedDay] = useState(selectedDay); + const displayedSelectedDayRef = useRef(selectedDay); + const weekStart = displayedWeekStart; + const monthStart = getMonthStart(displayedSelectedDay); + const monthWeeks = useMemo(() => getMonthWeeks(monthStart), [monthStart]); - return ( - - getWeekDays(prevWeekStart), [prevWeekStart]); + const currWeekDays = useMemo(() => getWeekDays(weekStart), [weekStart]); + const nextWeekDays = useMemo(() => getWeekDays(nextWeekStart), [nextWeekStart]); + + // ─── Single unified pan gesture ────────────────────────────────────── + const swipeX = useSharedValue(0); + const expandProgress = useSharedValue(0); // 0=week, 1=month + const expandStartRef = useSharedValue(0); + const gestureDirection = useSharedValue<"none" | "h" | "v">("none"); + const hapticFiredRef = useRef(false); + const panelWidth = screenWidth; + + const fireHapticOnce = useCallback(() => { + if (!hapticFiredRef.current) { + hapticFiredRef.current = true; + triggerSelectionHaptic(); + } + }, []); + const resetHaptic = useCallback(() => { + hapticFiredRef.current = false; + }, []); + const commitWeekSwipe = useCallback( + (deltaWeeks: number) => { + const nextWeekStart = addDays(displayedWeekStartRef.current, deltaWeeks * 7); + displayedWeekStartRef.current = nextWeekStart; + setDisplayedWeekStart(nextWeekStart); + + const nextSelectedDay = addDays(displayedSelectedDayRef.current, deltaWeeks * 7); + displayedSelectedDayRef.current = nextSelectedDay; + setDisplayedSelectedDay(nextSelectedDay); + + swipeX.value = 0; + onWeekChange(deltaWeeks); + }, + [onWeekChange, swipeX], + ); + + useEffect(() => { + displayedSelectedDayRef.current = selectedDay; + setDisplayedSelectedDay(selectedDay); + + if (displayedWeekStartRef.current === selectedWeekStart) { + return; + } + displayedWeekStartRef.current = selectedWeekStart; + setDisplayedWeekStart(selectedWeekStart); + }, [selectedDay, selectedWeekStart]); + + const panGesture = Gesture.Pan() + .minDistance(5) + .onStart(() => { + gestureDirection.value = "none"; + expandStartRef.value = expandProgress.value; + }) + .onUpdate((e) => { + // Lock direction on first significant movement + if (gestureDirection.value === "none") { + if ( + Math.abs(e.translationX) > 10 && + Math.abs(e.translationX) > Math.abs(e.translationY) * 1.2 + ) { + gestureDirection.value = "h"; + } else if (Math.abs(e.translationY) > 6) { + gestureDirection.value = "v"; + } + return; + } + + if (gestureDirection.value === "h") { + swipeX.value = e.translationX; + if (Math.abs(e.translationX) > SWIPE_THRESHOLD) { + runOnJS(fireHapticOnce)(); + } + } else { + // Vertical: map drag to expand progress + const dragRange = Math.max(monthExtraHeight, 100) * 1.2; + const rawProgress = expandStartRef.value + e.translationY / dragRange; + expandProgress.value = Math.max(0, Math.min(1, rawProgress)); + } + }) + .onEnd((e) => { + runOnJS(resetHaptic)(); + + if (gestureDirection.value === "h") { + const isExpanded = expandProgress.value > 0.5; + const weekDelta = isExpanded ? 4 : 1; // month vs week navigation + + if (e.translationX < -SWIPE_THRESHOLD || (e.velocityX < -500 && e.translationX < -20)) { + swipeX.value = withTiming(-panelWidth, { duration: 200 }, () => { + runOnJS(commitWeekSwipe)(weekDelta); + }); + } else if (e.translationX > SWIPE_THRESHOLD || (e.velocityX > 500 && e.translationX > 20)) { + swipeX.value = withTiming(panelWidth, { duration: 200 }, () => { + runOnJS(commitWeekSwipe)(-weekDelta); + }); + } else { + swipeX.value = withSpring(0, { damping: 20, stiffness: 300 }); + } + } else if (gestureDirection.value === "v") { + if (expandProgress.value > 0.35) { + expandProgress.value = withSpring(1, { damping: 18, stiffness: 200 }); + } else { + expandProgress.value = withSpring(0, { damping: 18, stiffness: 200 }); + } + runOnJS(triggerSelectionHaptic)(); + } + + gestureDirection.value = "none"; + }); + + // Animated styles + const swipeStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: swipeX.value }], + })); + + const containerAnimStyle = useAnimatedStyle(() => ({ + height: + HEADER_HEIGHT + + LABELS_HEIGHT + + weekHeight + + expandProgress.value * monthExtraHeight + + DRAG_HANDLE_HEIGHT, + })); + + const extraRowsContainerStyle = useAnimatedStyle(() => ({ + opacity: withTiming(expandProgress.value > 0.06 ? 1 : 0, { duration: 120 }), + height: expandProgress.value * monthExtraHeight, + overflow: "hidden" as const, + })); + + const renderDayCell = (dayKey: string, isTriptychSide = false) => { + const isSelected = !isTriptychSide && dayKey === displayedSelectedDay; + const isToday = dayKey === todayKey; + const hasLessons = (lessonCountByDay.get(dayKey) ?? 0) > 0; + const lessonCount = lessonCountByDay.get(dayKey) ?? 0; + const isCurrentMonth = isSameMonth(dayKey, displayedSelectedDay); + const dimmed = !isCurrentMonth; + const dayDateLabel = new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { + weekday: "long", + month: "long", + day: "numeric", + }); + + return ( + { + onDayPress(dayKey); + triggerSelectionHaptic(); }} + style={wStyles.dayCell} > - {/* Timeline accent line */} - - {/* Content */} - - {/* Top row: time + lifecycle */} - + - - {formatTime(row.startTime, locale)} – {formatTime(row.endTime, locale)} - + {formatDayNumber(dayKey)} + + + {hasLessons && !isSelected ? ( + + ) : ( + + )} + + ); + }; - {/* Simple colored dot for lifecycle status */} - {row.lifecycle !== "past" && ( - - - - {lifecycleTone.label} + const firstWeekRow = currWeekDays; + + return ( + + {/* Header */} + + + + {formatMonthYear(displayedSelectedDay, locale)} + + + + + {displayedSelectedDay !== todayKey ? ( + + + + {todayLabel} - )} - + + ) : null} + + - - - - {row.sport} - - - {counterpart} - - - - {lifecycleTone.label} + {/* Weekday labels */} + + {getWeekDays(getWeekStart(todayKey)).map((d) => ( + + + {formatWeekdayLetter(d, locale)} - + ))} + + {/* Gesture area — swipe wraps EVERYTHING so month grid moves too */} + + + {/* First row: triptych (prev | current | next) */} + + + {prevWeekDays.map((d) => renderDayCell(d, true))} + + + {firstWeekRow.map((d) => renderDayCell(d))} + + + {nextWeekDays.map((d) => renderDayCell(d, true))} + + + + {/* Extra month rows (revealed by vertical drag) */} + + {monthWeeks.slice(1).map((week) => ( + + {week.map((d) => renderDayCell(d))} + + ))} + + + + + {/* Drag handle — enlarged touch target */} + { + // Tap handle to toggle + if (expandProgress.value > 0.5) { + expandProgress.value = withSpring(0, { + damping: 18, + stiffness: 200, + }); + } else { + expandProgress.value = withSpring(1, { + damping: 18, + stiffness: 200, + }); + } + triggerSelectionHaptic(); + }} + hitSlop={{ top: 12, bottom: 12, left: 40, right: 40 }} + > + + ); } -// ─── CalendarTabScreen ──────────────────────────────────────────────────────── +const wStyles = StyleSheet.create({ + container: { + borderBottomLeftRadius: 24, + borderBottomRightRadius: 24, + borderCurve: "continuous", + overflow: "hidden", + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + height: HEADER_HEIGHT, + }, + monthButton: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + monthLabel: { + fontSize: 18, + fontWeight: "600", + }, + monthChevron: { + fontSize: 11, + marginTop: 2, + }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + todayPill: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 999, + borderCurve: "continuous", + }, + todayPillText: { + fontSize: 12, + fontWeight: "600", + }, + weekdayLabels: { + flexDirection: "row", + paddingHorizontal: 8, + height: LABELS_HEIGHT, + alignItems: "center", + }, + weekdayLabelCell: { + flex: 1, + alignItems: "center", + }, + weekdayLabel: { + fontSize: 11, + fontWeight: "500", + }, + triptych: { + flexDirection: "row", + }, + weekRow: { + flexDirection: "row", + paddingHorizontal: 8, + height: WEEK_ROW_HEIGHT, + alignItems: "center", + }, + dayCell: { + flex: 1, + alignItems: "center", + gap: 1, + }, + dayCircle: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: "center", + justifyContent: "center", + }, + dayNumber: { + fontSize: 15, + fontWeight: "400", + includeFontPadding: false, + }, + dot: { + width: 4, + height: 4, + borderRadius: 2, + }, + dotSpacer: { + width: 4, + height: 4, + }, + dragHandle: { + alignItems: "center", + height: DRAG_HANDLE_HEIGHT, + justifyContent: "center", + }, + dragBar: { + width: 36, + height: 4, + borderRadius: 2, + opacity: 0.4, + }, +}); + +// ─── Main Component ────────────────────────────────────────────────────────── export default function CalendarTabScreen() { const { t, i18n } = useTranslation(); const palette = useBrand(); - const { tabContentBottom } = useAppInsets(); - const { width } = useWindowDimensions(); - const locale = i18n.language; - const isDesktopWeb = Platform.OS === "web" && width >= 1180; + const { safeTop, tabContentBottom } = useAppInsets(); + const { isDesktopWeb } = useLayoutBreakpoint(); const todayKey = useMemo(() => toDayKey(Date.now()), []); - const [showDatePicker, setShowDatePicker] = useState(false); const { selectedDay, - selectedWeekDays, listRef, - sections, + listItems, lessonCountByDay, + canShowGoogleAgenda, + viewMode, + setViewMode, + viewabilityConfig, + onViewableItemsChanged, handleDayPress, handleWeekChange, handleTodayPress, + openMonthPicker, + overrideItemLayout, + selectedDayTimestamp, isLoading, - } = useCalendarTabController({ locale }); - const [pickerDate, setPickerDate] = useState(() => new Date(dayKeyToTimestamp(selectedDay))); - + } = useCalendarTabController(); + const selectedWeekDays = useMemo(() => getWeekDays(getWeekStart(selectedDay)), [selectedDay]); + const [showDatePicker, setShowDatePicker] = useState(false); + const [pickerDate, setPickerDate] = useState(() => new Date(selectedDayTimestamp)); const selectedLessonCount = lessonCountByDay.get(selectedDay) ?? 0; - const selectedWeekLessonCount = useMemo( - () => - selectedWeekDays.reduce((total, dayKey) => total + (lessonCountByDay.get(dayKey) ?? 0), 0), - [lessonCountByDay, selectedWeekDays], + + // ─── Render items ─────────────────────────────────────────────────────────── + + const railColor = (palette.border as string) ?? "#E5E5E5"; + const listFooterComponent = useMemo( + () => , + [tabContentBottom], ); useEffect(() => { - setPickerDate(new Date(dayKeyToTimestamp(selectedDay))); - }, [selectedDay]); + setPickerDate(new Date(selectedDayTimestamp)); + }, [selectedDayTimestamp]); const handleDateChange = useCallback( - (_event: unknown, selectedDate?: Date) => { - if (!selectedDate) { + (_event: unknown, nextDate?: Date) => { + if (!nextDate) { if (Platform.OS !== "ios") { setShowDatePicker(false); } @@ -450,12 +921,12 @@ export default function CalendarTabScreen() { } if (Platform.OS === "ios") { - setPickerDate(selectedDate); + setPickerDate(nextDate); return; } setShowDatePicker(false); - handleDayPress(toDayKey(selectedDate.getTime())); + handleDayPress(toDayKey(nextDate.getTime())); }, [handleDayPress], ); @@ -468,125 +939,243 @@ export default function CalendarTabScreen() { } }, [handleDayPress, pickerDate, selectedDay]); - const renderItem = useCallback( - ({ item, index }: SectionListRenderItemInfo & { index: number }) => { - if (item.kind === "empty") { - return ( - - - - - {t("calendarTab.timeline.noLessons", { defaultValue: "No lessons" })} - - - {t("calendarTab.timeline.noLessonsHint", { - defaultValue: "Nothing is scheduled for this day yet.", - })} - - - - ); - } - - return ; - }, - [locale, palette, t], - ); - - const renderSectionHeader = useCallback( - ({ section }: { section: AgendaSection }) => { - const lessonCount = lessonCountByDay.get(section.dayKey) ?? 0; - const hasSessions = lessonCount > 0; - - return ( + const agendaHeaderComponent = useMemo( + () => ( + - - - {formatSectionTitle(section.dayKey, locale)} + + + {t("calendarTab.agenda.title", { defaultValue: "Agenda" })} - - {formatSectionSubtitle(section.dayKey, locale)} + + {formatSelectedDayLabel(selectedDay, i18n.language)} - - {/* Session count pill */} 0 + ? (palette.primarySubtle as string) + : (palette.surface as string), }} > 0 + ? (palette.primary as string) + : (palette.textMuted as string), + fontVariant: ["tabular-nums"], }} > - {lessonCount === 0 - ? "Free" - : lessonCount === 1 - ? "1 lesson" - : `${String(lessonCount)} lessons`} + {selectedLessonCount === 1 + ? t("calendarTab.agenda.oneSession", { defaultValue: "1 session" }) + : t("calendarTab.agenda.sessionCount", { + count: selectedLessonCount, + defaultValue: `${String(selectedLessonCount)} sessions`, + })} + + ), + [i18n.language, palette, selectedDay, selectedLessonCount, t], + ); + + const renderItem = useCallback( + ({ item }: { item: TimelineListItem }) => { + if (item.kind === "dayHeader") { + const isToday = item.dayKey === todayKey; + const dotColor = isToday ? (palette.primary as string) : (palette.textMuted as string); + + return ( + + + + + + + + {formatDayHeading(item.dayKey, i18n.language)} + + + {formatDaySubtitle(item.dayKey, i18n.language)} + + + + ); + } + + if (item.kind === "empty") { + return ( + + + + + + + + + {t("calendarTab.timeline.noLessons", { + defaultValue: "No lessons", + })} + + + {t("calendarTab.timeline.noLessonsHint", { + defaultValue: "Nothing is scheduled for this day yet.", + })} + + + + + ); + } + + const row = item.lesson; + const swatches = palette.calendar.eventSwatches; + const swatch = swatches[hashSport(row.sport) % Math.max(swatches.length, 1)] ?? undefined; + const accent = (swatch?.background as string) ?? (palette.primary as string); + const counterpart = + row.source === "google" + ? (row.location ?? "Google Calendar") + : row.roleView === "instructor" + ? row.studioName + : (row.instructorName ?? "Unassigned instructor"); + const timeLabel = row.isAllDay + ? t("calendarTab.timeline.allDay", { defaultValue: "All day" }) + : `${formatTime(row.startTime, i18n.language)} – ${formatTime(row.endTime, i18n.language)}`; + + const lifecycleLabel = + row.lifecycle === "live" + ? t("calendarTab.timeline.lifecycle.live", { + defaultValue: "Live now", + }) + : row.lifecycle === "upcoming" + ? t("calendarTab.timeline.lifecycle.upcoming", { + defaultValue: "Upcoming", + }) + : row.lifecycle === "cancelled" + ? t("calendarTab.timeline.lifecycle.cancelled", { + defaultValue: "Cancelled", + }) + : t("calendarTab.timeline.lifecycle.past", { + defaultValue: "Past", + }); + + const lifecycleTone = + row.lifecycle === "live" + ? { + fg: palette.success as string, + bg: palette.successSubtle as string, + } + : row.lifecycle === "upcoming" + ? { + fg: palette.primary as string, + bg: palette.primarySubtle as string, + } + : row.lifecycle === "cancelled" + ? { + fg: palette.danger as string, + bg: palette.dangerSubtle as string, + } + : { + fg: palette.textMuted as string, + bg: palette.surfaceAlt as string, + }; + + return ( + + + + + + handleDayPress(item.dayKey)} + style={[styles.lessonCard, { backgroundColor: palette.surfaceElevated as string }]} + > + + + + {timeLabel} + + + {row.source === "google" ? ( + + + {t("calendarTab.timeline.googleBadge", { defaultValue: "Google" })} + + + ) : null} + + + {lifecycleLabel} + + + + + + {row.sport} + + + {counterpart} + + + + ); }, - [lessonCountByDay, locale, palette], + [handleDayPress, i18n.language, palette, railColor, t, todayKey], ); - const listHeaderComponent = useMemo(() => , []); + // ─── Loading ──────────────────────────────────────────────────────────────── if (isLoading) { return ; @@ -594,48 +1183,32 @@ export default function CalendarTabScreen() { if (isDesktopWeb) { return ( - - { - if (showDatePicker) { - handleDoneWithDatePicker(); - return; - } - setShowDatePicker(true); - }} - onDismissDatePicker={() => setShowDatePicker(false)} - /> - + + + + {t("calendarTab.desktopSoon", { + defaultValue: "Desktop calendar board is temporarily unavailable in this merge.", + })} + + + ); } + // ─── Render ───────────────────────────────────────────────────────────────── + return ( - - + - + - - - Calendar - + - {formatMonthLabel(selectedDay, locale)} + {formatMonthYear(selectedDay, i18n.language)} - - {formatSelectedDayLabel(selectedDay, locale)} + + {formatSelectedDayLabel(selectedDay, i18n.language)} @@ -687,13 +1244,18 @@ export default function CalendarTabScreen() { /> ) : null} { if (showDatePicker) { handleDoneWithDatePicker(); return; } setShowDatePicker(true); + openMonthPicker(); }} variant="secondary" size="sm" @@ -708,69 +1270,49 @@ export default function CalendarTabScreen() { alignItems: "center", justifyContent: "space-between", gap: BrandSpacing.md, - borderRadius: 24, - borderCurve: "continuous", - backgroundColor: palette.surfaceAlt as string, - paddingHorizontal: BrandSpacing.md, - paddingVertical: BrandSpacing.md, + borderTopWidth: 1, + borderColor: palette.border as string, + paddingTop: BrandSpacing.md, }} > - - - Focus lane - - - {selectedLessonCount === 0 - ? "Open day" - : selectedLessonCount === 1 - ? "1 session on deck" - : `${String(selectedLessonCount)} sessions on deck`} - - - {selectedWeekLessonCount === 0 - ? "Use jump or swipe the week rail to plan ahead." - : `${String(selectedWeekLessonCount)} total sessions in this week`} - - - + + {formatSelectedDayLabel(selectedDay, i18n.language)} + 0 + ? (palette.primarySubtle as string) + : (palette.surface as string), }} > 0 ? (palette.primary as string) : (palette.textMuted as string), + fontVariant: ["tabular-nums"], }} > - {selectedLessonCount === 0 ? "OPEN" : String(selectedLessonCount)} - - - {selectedDay === todayKey ? "today" : "selected"} + {selectedLessonCount === 1 + ? t("calendarTab.agenda.oneSession", { defaultValue: "1 session" }) + : t("calendarTab.agenda.sessionCount", { + count: selectedLessonCount, + defaultValue: `${String(selectedLessonCount)} sessions`, + })} + {canShowGoogleAgenda ? ( + + value={viewMode} + onChange={setViewMode} + options={[ + { + value: "jobs_only", + label: t("calendarTab.filters.jobsOnly", { defaultValue: "Jobs only" }), + }, + { + value: "jobs_and_google", + label: t("calendarTab.filters.jobsAndGoogle", { + defaultValue: "Jobs + Google", + }), + }, + ]} + /> + ) : null} + {showDatePicker ? ( ) : null} - + - {/* Agenda list */} - item.key} renderItem={renderItem} - renderSectionHeader={renderSectionHeader} - stickySectionHeadersEnabled - contentInsetAdjustmentBehavior="automatic" + getItemType={(item) => item.kind} + overrideItemLayout={overrideItemLayout} showsVerticalScrollIndicator={false} - ListHeaderComponent={listHeaderComponent} - contentContainerStyle={{ - paddingBottom: tabContentBottom + 28, - paddingHorizontal: BrandSpacing.lg, - gap: BrandSpacing.sm, - }} - initialNumToRender={10} - maxToRenderPerBatch={8} - updateCellsBatchingPeriod={60} - windowSize={6} - removeClippedSubviews={Platform.OS === "android"} - SectionSeparatorComponent={() => } - ItemSeparatorComponent={() => } + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + contentContainerStyle={[styles.timelineContent, { paddingHorizontal: BrandSpacing.lg }]} + ListHeaderComponent={agendaHeaderComponent} + ListFooterComponent={listFooterComponent} /> - + ); } + +// ─── Timeline Styles ───────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + timelineContent: { + paddingTop: 4, + }, + filterBar: { + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: 4, + }, + + // ── Rail ───────────────────────────────────────── + timelineRow: { + flexDirection: "row", + alignItems: "stretch", + paddingLeft: 8, + }, + railGutter: { + width: RAIL_LEFT * 2, + alignItems: "center", + position: "relative", + }, + railLine: { + position: "absolute", + width: 2, + top: 0, + bottom: 0, + left: RAIL_LEFT - 1, + borderRadius: 1, + opacity: 0.3, + }, + railDotDay: { + width: RAIL_DOT_DAY, + height: RAIL_DOT_DAY, + borderRadius: RAIL_DOT_DAY / 2, + marginTop: 18, + zIndex: 1, + }, + railDotLesson: { + width: RAIL_DOT_LESSON, + height: RAIL_DOT_LESSON, + borderRadius: RAIL_DOT_LESSON / 2, + marginTop: 20, + zIndex: 1, + }, + + // ── Day header (month+number first, weekday underneath) ── + dayHeaderContent: { + flex: 1, + paddingTop: 12, + paddingBottom: 6, + paddingRight: 16, + }, + dayHeading: { + fontSize: 20, + fontWeight: "600", + lineHeight: 26, + }, + daySubtitle: { + fontSize: 13, + fontWeight: "400", + marginTop: 1, + }, + + // ── Lesson card ────────────────────────────────── + lessonCard: { + flex: 1, + marginRight: 16, + marginBottom: 8, + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 16, + borderCurve: "continuous", + }, + lessonContent: { + gap: 3, + }, + lessonTopRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + }, + lessonTime: { + fontSize: 13, + fontWeight: "500", + }, + lessonBadgeRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + lifecycleBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + borderCurve: "continuous", + }, + lifecycleBadgeText: { + fontSize: 11, + fontWeight: "600", + }, + sourceBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + borderCurve: "continuous", + }, + sourceBadgeText: { + fontSize: 11, + fontWeight: "600", + }, + lessonTitle: { + fontSize: 16, + fontWeight: "500", + lineHeight: 21, + }, + lessonMeta: { + fontSize: 14, + fontWeight: "400", + }, + + // ── Empty ──────────────────────────────────────── + emptyContent: { + flex: 1, + paddingVertical: 8, + paddingRight: 16, + }, + emptyText: { + fontSize: 13, + fontWeight: "400", + }, + emptyStateCard: { + flex: 1, + marginRight: 16, + marginBottom: 8, + paddingHorizontal: 18, + paddingVertical: 20, + borderRadius: 20, + borderCurve: "continuous", + alignItems: "center", + gap: 8, + }, + emptyStateTitle: { + fontSize: 15, + fontWeight: "600", + textAlign: "center", + }, + emptyStateBody: { + fontSize: 13, + fontWeight: "400", + textAlign: "center", + }, +}); diff --git a/src/components/calendar/calendar-web-board.tsx b/src/components/calendar/calendar-web-board.tsx deleted file mode 100644 index 33f7351e..00000000 --- a/src/components/calendar/calendar-web-board.tsx +++ /dev/null @@ -1,807 +0,0 @@ -import DateTimePicker from "@react-native-community/datetimepicker"; -import { useMemo } from "react"; -import { ScrollView, Text, View } from "react-native"; - -import { KitButton, KitPressable } from "@/components/ui/kit"; -import { BrandType } from "@/constants/brand"; -import { useBrand } from "@/hooks/use-brand"; -import { formatTime } from "@/lib/jobs-utils"; -import type { AgendaSection, TimelineRow } from "./use-calendar-tab-controller"; -import { dayKeyToTimestamp } from "./use-calendar-tab-controller"; - -const DEFAULT_GRID_START_HOUR = 6; -const DEFAULT_GRID_END_HOUR = 23; -const MIN_GRID_START_HOUR = 5; -const MAX_GRID_END_HOUR = 24; -const HOUR_ROW_HEIGHT = 68; -const DAY_COLUMN_WIDTH = 212; - -type CalendarWebBoardProps = { - locale: string; - selectedDay: string; - todayKey: string; - weekDays: string[]; - sections: AgendaSection[]; - onSelectDay: (dayKey: string) => void; - onChangeWeek: (deltaWeeks: number) => void; - onTodayPress: () => void; - showDatePicker: boolean; - pickerDate: Date; - onDateChange: (_event: unknown, selectedDate?: Date) => void; - onToggleDatePicker: () => void; - onDismissDatePicker: () => void; -}; - -function formatMonthLabel(dayKey: string, locale: string) { - return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { - month: "long", - year: "numeric", - }); -} - -function formatWeekRangeLabel(weekDays: string[], locale: string) { - const start = new Date(dayKeyToTimestamp(weekDays[0] ?? "")); - const end = new Date(dayKeyToTimestamp(weekDays[weekDays.length - 1] ?? "")); - const startLabel = start.toLocaleDateString(locale, { month: "short", day: "numeric" }); - const endLabel = end.toLocaleDateString(locale, { month: "short", day: "numeric" }); - return `${startLabel} - ${endLabel}`; -} - -function formatSelectedDayLabel(dayKey: string, locale: string) { - return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { - weekday: "long", - month: "long", - day: "numeric", - }); -} - -function formatWeekdayLabel(dayKey: string, locale: string) { - return new Date(dayKeyToTimestamp(dayKey)).toLocaleDateString(locale, { weekday: "short" }); -} - -function formatDayNumber(dayKey: string) { - return String(new Date(dayKeyToTimestamp(dayKey)).getDate()); -} - -function formatHourLabel(hour: number, locale: string) { - const date = new Date(); - date.setHours(hour, 0, 0, 0); - return date.toLocaleTimeString(locale, { - hour: "numeric", - minute: "2-digit", - }); -} - -function formatHoursMetric(hours: number) { - const rounded = Math.round(hours * 10) / 10; - if (rounded === 0) { - return "0h"; - } - return Number.isInteger(rounded) ? `${String(rounded)}h` : `${rounded.toFixed(1)}h`; -} - -function hashSport(sport: string) { - let hash = 0; - for (let index = 0; index < sport.length; index += 1) { - hash = sport.charCodeAt(index) + ((hash << 5) - hash); - } - return Math.abs(hash); -} - -function getRowsForSection(section: AgendaSection | undefined) { - if (!section) return [] as TimelineRow[]; - return section.data.flatMap((item) => (item.kind === "lesson" ? [item.lesson] : [])); -} - -function getDurationHours(rows: TimelineRow[]) { - return rows.reduce((total, row) => total + (row.endTime - row.startTime) / 3_600_000, 0); -} - -function buildLaneLayout(rows: TimelineRow[]) { - const sorted = [...rows].sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime); - const laneEndTimes: number[] = []; - const laneAssignments = new Map(); - - sorted.forEach((row) => { - let lane = laneEndTimes.findIndex((endTime) => row.startTime >= endTime); - if (lane === -1) { - lane = laneEndTimes.length; - laneEndTimes.push(row.endTime); - } else { - laneEndTimes[lane] = row.endTime; - } - laneAssignments.set(row.lessonId, lane); - }); - - return { laneAssignments, laneCount: Math.max(laneEndTimes.length, 1) }; -} - -function resolveGridBounds(rows: TimelineRow[]) { - if (rows.length === 0) { - return { - gridStartHour: DEFAULT_GRID_START_HOUR, - gridEndHour: DEFAULT_GRID_END_HOUR, - }; - } - - let earliestHour = DEFAULT_GRID_START_HOUR; - let latestHour = DEFAULT_GRID_END_HOUR; - - rows.forEach((row) => { - const start = new Date(row.startTime); - const end = new Date(row.endTime); - const startHour = start.getHours() + start.getMinutes() / 60; - const endHour = end.getHours() + end.getMinutes() / 60; - earliestHour = Math.min(earliestHour, startHour); - latestHour = Math.max(latestHour, endHour); - }); - - const gridStartHour = Math.max(MIN_GRID_START_HOUR, Math.floor(earliestHour) - 1); - const gridEndHour = Math.min(MAX_GRID_END_HOUR, Math.ceil(latestHour) + 1); - - return { - gridStartHour, - gridEndHour: Math.max(gridEndHour, gridStartHour + 8), - }; -} - -function MetricTile({ - label, - value, - tone, -}: { - label: string; - value: string; - tone: "light" | "accent" | "dark"; -}) { - const palette = useBrand(); - const styles = - tone === "accent" - ? { - backgroundColor: palette.primary as string, - labelColor: "rgba(255,255,255,0.72)", - valueColor: palette.onPrimary as string, - } - : tone === "dark" - ? { - backgroundColor: palette.text as string, - labelColor: "rgba(255,255,255,0.72)", - valueColor: palette.surface as string, - } - : { - backgroundColor: palette.surfaceAlt as string, - labelColor: palette.textMuted as string, - valueColor: palette.text as string, - }; - - return ( - - - {label} - - - {value} - - - ); -} - -function FocusAgendaCard({ locale, row }: { locale: string; row: TimelineRow }) { - const palette = useBrand(); - const swatches = palette.calendar.eventSwatches; - const swatch = swatches[hashSport(row.sport) % Math.max(swatches.length, 1)] ?? undefined; - const accent = (swatch?.background as string) ?? (palette.primary as string); - - return ( - - - - {row.sport} - - - {row.roleView === "instructor" - ? row.studioName - : (row.instructorName ?? "Unassigned instructor")} - - - {formatTime(row.startTime, locale)} - {formatTime(row.endTime, locale)} - - - ); -} - -export function CalendarWebBoard({ - locale, - selectedDay, - todayKey, - weekDays, - sections, - onSelectDay, - onChangeWeek, - onTodayPress, - showDatePicker, - pickerDate, - onDateChange, - onToggleDatePicker, - onDismissDatePicker, -}: CalendarWebBoardProps) { - const palette = useBrand(); - const sectionMap = useMemo( - () => new Map(sections.map((section) => [section.dayKey, section])), - [sections], - ); - const weekRowsByDay = useMemo( - () => - weekDays.map((dayKey) => ({ - dayKey, - rows: getRowsForSection(sectionMap.get(dayKey)), - })), - [sectionMap, weekDays], - ); - const selectedRows = useMemo( - () => weekRowsByDay.find((entry) => entry.dayKey === selectedDay)?.rows ?? [], - [selectedDay, weekRowsByDay], - ); - const weekRows = useMemo(() => weekRowsByDay.flatMap((entry) => entry.rows), [weekRowsByDay]); - const weekSessionCount = weekRows.length; - const busyDayCount = weekRowsByDay.filter((entry) => entry.rows.length > 0).length; - const weekBookedHours = getDurationHours(weekRows); - const selectedBookedHours = getDurationHours(selectedRows); - const { gridStartHour, gridEndHour } = useMemo(() => resolveGridBounds(weekRows), [weekRows]); - const hours = useMemo( - () => Array.from({ length: gridEndHour - gridStartHour }, (_, index) => gridStartHour + index), - [gridEndHour, gridStartHour], - ); - const gridHeight = hours.length * HOUR_ROW_HEIGHT; - const now = Date.now(); - const todayOffsetHours = - new Date(now).getHours() + new Date(now).getMinutes() / 60 + new Date(now).getSeconds() / 3600; - const showNowLine = todayOffsetHours >= gridStartHour && todayOffsetHours <= gridEndHour; - - return ( - - - - - Planning board - - - {formatMonthLabel(selectedDay, locale)} - - - {formatWeekRangeLabel(weekDays, locale)}. One horizontal surface for scan, select, and - adjust. - - - - - - - - - - - - Focus day - - - {formatSelectedDayLabel(selectedDay, locale)} - - - {selectedRows.length === 0 - ? "No sessions are stacked here yet." - : selectedRows.length === 1 - ? "1 session loaded into the agenda rail." - : `${String(selectedRows.length)} sessions loaded into the agenda rail.`} - - - - - onChangeWeek(-1)} - variant="secondary" - size="sm" - fullWidth={false} - /> - - - onChangeWeek(1)} - variant="primary" - size="sm" - fullWidth={false} - /> - - - - {showDatePicker ? ( - - - - - - - - ) : null} - - - - - - Selected agenda - - - {selectedRows.length === 0 - ? "Open lane" - : selectedRows.length === 1 - ? "1 session" - : `${String(selectedRows.length)} sessions`} - - - {selectedRows.length === 0 - ? "A clear day gives you room to absorb new demand." - : `${formatHoursMetric(selectedBookedHours)} booked across the selected day.`} - - - - - {selectedRows.length === 0 ? ( - - - Nothing booked - - - Switch days directly from the planner header to review the rest of the week. - - - ) : ( - selectedRows.map((row) => ( - - )) - )} - - - - - - - - - {hours.map((hour, index) => ( - - - {formatHourLabel(hour, locale)} - - {index === 0 ? ( - - {`${String(weekSessionCount)} total`} - - ) : null} - - ))} - - - {weekRowsByDay.map(({ dayKey, rows }) => { - const { laneAssignments, laneCount } = buildLaneLayout(rows); - const dayLoadHours = getDurationHours(rows); - const laneWidth = - laneCount > 1 ? (DAY_COLUMN_WIDTH - 28) / laneCount : DAY_COLUMN_WIDTH - 28; - const selected = dayKey === selectedDay; - const today = dayKey === todayKey; - - return ( - - onSelectDay(dayKey)} - > - - - {formatWeekdayLabel(dayKey, locale)} - - - - {formatDayNumber(dayKey)} - - 0 - ? (palette.primary as string) - : (palette.textMuted as string), - }} - > - {rows.length === 0 - ? "Open" - : `${String(rows.length)} / ${formatHoursMetric(dayLoadHours)}`} - - - - - - - {hours.map((hour, index) => ( - - ))} - - {today && showNowLine ? ( - - ) : null} - - {rows.map((row) => { - const lane = laneAssignments.get(row.lessonId) ?? 0; - const dayStart = dayKeyToTimestamp(dayKey); - const startHourOffset = - (row.startTime - dayStart) / 3_600_000 - gridStartHour; - const durationHours = Math.max( - 0.75, - (row.endTime - row.startTime) / 3_600_000, - ); - const swatches = palette.calendar.eventSwatches; - const swatch = - swatches[hashSport(row.sport) % Math.max(swatches.length, 1)] ?? - undefined; - const accent = - (swatch?.background as string) ?? (palette.primary as string); - const top = Math.max(8, startHourOffset * HOUR_ROW_HEIGHT + 8); - const height = Math.max(58, durationHours * HOUR_ROW_HEIGHT - 10); - - return ( - - - {row.sport} - - - {formatTime(row.startTime, locale)} -{" "} - {formatTime(row.endTime, locale)} - - - {row.roleView === "instructor" - ? row.studioName - : (row.instructorName ?? "Unassigned instructor")} - - - ); - })} - - - ); - })} - - - - - - - ); -} diff --git a/src/components/calendar/use-calendar-tab-controller.ts b/src/components/calendar/use-calendar-tab-controller.ts index 2ca740f2..05792ae2 100644 --- a/src/components/calendar/use-calendar-tab-controller.ts +++ b/src/components/calendar/use-calendar-tab-controller.ts @@ -1,31 +1,64 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { FlashListRef } from "@shopify/flash-list"; import { useAction, useQuery } from "convex/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { InteractionManager, type SectionList } from "react-native"; +import { Platform, type ViewToken } from "react-native"; import { api } from "@/convex/_generated/api"; import { syncDeviceCalendarEvents } from "@/lib/device-calendar-sync"; const calendarApi = (api as unknown as { calendar: Record }).calendar as { + getMyGoogleCalendarAgenda: unknown; + getMyGoogleCalendarStatus: unknown; syncMyGoogleCalendarEvents: unknown; }; +export type CalendarViewMode = "jobs_only" | "jobs_and_google"; + +type GoogleCalendarStatus = { + connected: boolean; +}; + +type GoogleAgendaRow = { + providerEventId: string; + title: string; + status: "confirmed" | "tentative" | "cancelled"; + startTime: number; + endTime: number; + isAllDay: boolean; + location?: string; + htmlLink?: string; +}; + export type TimelineRow = { lessonId: string; + source: "job" | "google"; roleView: "instructor" | "studio"; studioName: string; instructorName?: string; sport: string; startTime: number; endTime: number; - status: "open" | "filled" | "cancelled" | "completed"; + status: + | "open" + | "filled" + | "cancelled" + | "completed" + | "confirmed" + | "tentative"; lifecycle: "upcoming" | "live" | "past" | "cancelled"; + isAllDay?: boolean; + location?: string; + htmlLink?: string; }; -export type AgendaItem = +export type TimelineListItem = + | { kind: "dayHeader"; key: string; dayKey: string } | { kind: "empty"; key: string; dayKey: string } | { kind: "lesson"; key: string; dayKey: string; lesson: TimelineRow }; +export type AgendaItem = TimelineListItem; + export type AgendaSection = { key: string; dayKey: string; @@ -34,71 +67,83 @@ export type AgendaSection = { const DAY_MS = 24 * 60 * 60 * 1000; const CACHE_TTL_MS = 15 * 60 * 1000; -const CACHE_VERSION = 2; -const TIMELINE_RANGE_DAYS = 120; -const TIMELINE_EXTEND_BUFFER_DAYS = 45; - -export function toDayKey(timestamp: number) { +const CACHE_VERSION = 3; +const TIMELINE_RANGE_DAYS = 90; +const TIMELINE_EXTEND_BUFFER_DAYS = 60; +const ESTIMATED_DAY_HEADER_SIZE = 64; +const ESTIMATED_LESSON_SIZE = 84; +const ESTIMATED_EMPTY_SIZE = 40; +const GOOGLE_VIEW_MODE_STORAGE_KEY = "calendar:view-mode:v1"; + +function toDayKey(timestamp: number) { const d = new Date(timestamp); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } -export function dayKeyToTimestamp(dayKey: string) { +function dayKeyToTimestamp(dayKey: string) { const [y, m, d] = dayKey.split("-").map(Number) as [number, number, number]; return new Date(y, m - 1, d).getTime(); } -export function addDays(dayKey: string, delta: number) { +function addDays(dayKey: string, delta: number) { return toDayKey(dayKeyToTimestamp(dayKey) + delta * DAY_MS); } -export function compareDayKey(a: string, b: string) { +function compareDayKey(a: string, b: string) { return a < b ? -1 : a > b ? 1 : 0; } -export function resolveFirstDayOfWeek(locale: string) { - try { - const localeInfo = new Intl.Locale(locale) as Intl.Locale & { - weekInfo?: { firstDay?: number }; - }; - const firstDay = localeInfo.weekInfo?.firstDay; - if (typeof firstDay === "number") { - return firstDay % 7; - } - } catch { - // Fall back below. - } - - return locale.toLowerCase().startsWith("en-us") ? 0 : 1; -} - -export function getWeekStart(dayKey: string, firstDayOfWeek: number) { - const timestamp = dayKeyToTimestamp(dayKey); - const date = new Date(timestamp); - const dayOfWeek = date.getDay(); - const offset = (7 + dayOfWeek - firstDayOfWeek) % 7; - return toDayKey(timestamp - offset * DAY_MS); -} - -export function getWeekDays(weekStartKey: string) { - return Array.from({ length: 7 }, (_, index) => addDays(weekStartKey, index)); -} - function buildTimelineRowsSignature(rows: TimelineRow[]) { if (rows.length === 0) { return "0"; } - return rows - .map((row) => `${row.lessonId}:${row.startTime}:${row.endTime}:${row.status}:${row.lifecycle}`) + .map( + (row) => + `${row.source}:${row.lessonId}:${row.sport}:${row.startTime}:${row.endTime}:${row.status}:${row.lifecycle}`, + ) .sort() .join("|"); } -function useTimelineCache(role: string | undefined, startTime: number, endTime: number) { +function enumerateDays(startKey: string, endKey: string) { + const out: string[] = []; + let cursor = startKey; + while (compareDayKey(cursor, endKey) <= 0) { + out.push(cursor); + cursor = addDays(cursor, 1); + } + return out; +} + +function getLifecycle( + status: TimelineRow["status"], + now: number, + startTime: number, + endTime: number, +): TimelineRow["lifecycle"] { + if (status === "cancelled") { + return "cancelled"; + } + if (now < startTime) { + return "upcoming"; + } + if (now <= endTime) { + return "live"; + } + return "past"; +} + +function useTimelineCache( + role: string | undefined, + startTime: number, + endTime: number, + viewMode: CalendarViewMode, +) { const cacheKey = useMemo( - () => `calendar:timeline:v${CACHE_VERSION}:${role ?? "none"}:${startTime}:${endTime}`, - [role, startTime, endTime], + () => + `calendar:timeline:v${CACHE_VERSION}:${role ?? "none"}:${viewMode}:${startTime}:${endTime}`, + [endTime, role, startTime, viewMode], ); const [cachedRows, setCachedRows] = useState(null); const [cacheReady, setCacheReady] = useState(false); @@ -107,7 +152,6 @@ function useTimelineCache(role: string | undefined, startTime: number, endTime: let cancelled = false; setCachedRows(null); setCacheReady(false); - void (async () => { try { const raw = await AsyncStorage.getItem(cacheKey); @@ -115,7 +159,6 @@ function useTimelineCache(role: string | undefined, startTime: number, endTime: setCacheReady(true); return; } - const payload = JSON.parse(raw) as { fetchedAt: number; rows: TimelineRow[]; @@ -126,14 +169,13 @@ function useTimelineCache(role: string | undefined, startTime: number, endTime: } setCachedRows(payload.rows); } catch { - // Ignore cache read failures. + /* ignore */ } finally { if (!cancelled) { setCacheReady(true); } } })(); - return () => { cancelled = true; }; @@ -144,7 +186,7 @@ function useTimelineCache(role: string | undefined, startTime: number, endTime: try { await AsyncStorage.setItem(cacheKey, JSON.stringify({ fetchedAt: Date.now(), rows })); } catch { - // Ignore cache write failures. + /* best-effort */ } }, [cacheKey], @@ -153,7 +195,9 @@ function useTimelineCache(role: string | undefined, startTime: number, endTime: return { cachedRows, cacheReady, persist }; } -export function useCalendarTabController({ locale }: { locale: string }) { +type ItemLayout = { span?: number; size?: number }; + +export function useCalendarTabController() { const currentUser = useQuery(api.users.getCurrentUser); const todayKey = useMemo(() => toDayKey(Date.now()), []); const [selectedDay, setSelectedDay] = useState(todayKey); @@ -162,11 +206,12 @@ export function useCalendarTabController({ locale }: { locale: string }) { start: addDays(todayKey, -TIMELINE_RANGE_DAYS), end: addDays(todayKey, TIMELINE_RANGE_DAYS), })); - const listRef = useRef>(null); - const pendingScrollRequestRef = useRef<{ - dayKey: string; - animated: boolean; - } | null>(null); + const [showMonthPicker, setShowMonthPicker] = useState(false); + const [viewMode, setViewModeState] = useState("jobs_only"); + const [viewModeReady, setViewModeReady] = useState(false); + const listRef = useRef>(null); + const programmaticScrollRef = useRef(false); + const lastViewSyncAtRef = useRef(0); const role = currentUser?.role === "instructor" || currentUser?.role === "studio" @@ -178,43 +223,148 @@ export function useCalendarTabController({ locale }: { locale: string }) { const timelineArgs = useMemo(() => ({ startTime, endTime, limit: 1000 }), [endTime, startTime]); const remoteRows = useQuery(api.jobs.getMyCalendarTimeline, role ? timelineArgs : "skip"); - const remoteTimelineRows = useMemo( - () => (remoteRows ? (remoteRows as unknown as TimelineRow[]) : null), - [remoteRows], + const googleStatus = useQuery( + calendarApi.getMyGoogleCalendarStatus as any, + role ? {} : "skip", + ) as GoogleCalendarStatus | undefined; + + const emptyArgs = useMemo(() => ({}), []); + const instructorSettings = useQuery( + api.users.getMyInstructorSettings, + currentUser?.role === "instructor" ? emptyArgs : "skip", ); - const remoteRowsSignature = useMemo( - () => (remoteTimelineRows ? buildTimelineRowsSignature(remoteTimelineRows) : ""), - [remoteTimelineRows], + const studioSettings = useQuery( + api.users.getMyStudioSettings, + currentUser?.role === "studio" ? emptyArgs : "skip", ); - const lastPersistSignatureRef = useRef(""); + const calendarSettings = role === "instructor" ? instructorSettings : studioSettings; - const syncGoogleCalendar = useAction( - calendarApi.syncMyGoogleCalendarEvents as never, - ) as unknown as (args: { + const googleAgendaRows = useQuery( + calendarApi.getMyGoogleCalendarAgenda as any, + role && viewMode === "jobs_and_google" && googleStatus?.connected + ? timelineArgs + : "skip", + ) as GoogleAgendaRow[] | undefined; + + const syncGoogleCalendar = useAction(calendarApi.syncMyGoogleCalendarEvents as any) as (args: { startTime?: number; endTime?: number; limit?: number; }) => Promise; - const emptyArgs = useMemo(() => ({}), []); - const instructorSettings = useQuery( - api.users.getMyInstructorSettings, - currentUser?.role === "instructor" ? emptyArgs : "skip", + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const stored = await AsyncStorage.getItem(GOOGLE_VIEW_MODE_STORAGE_KEY); + if (cancelled) { + return; + } + if (stored === "jobs_only" || stored === "jobs_and_google") { + setViewModeState(stored); + } + } finally { + if (!cancelled) { + setViewModeReady(true); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const canShowGoogleAgenda = Boolean(role && googleStatus?.connected === true); + const resolvedViewMode: CalendarViewMode = + canShowGoogleAgenda && viewMode === "jobs_and_google" ? "jobs_and_google" : "jobs_only"; + + const setViewMode = useCallback((nextMode: CalendarViewMode) => { + setViewModeState(nextMode); + void AsyncStorage.setItem(GOOGLE_VIEW_MODE_STORAGE_KEY, nextMode).catch(() => { + /* best-effort */ + }); + }, []); + + const remoteJobTimelineRows = useMemo(() => { + if (!remoteRows) { + return null; + } + return (remoteRows as unknown as Omit[]).map((row) => ({ + ...row, + source: "job" as const, + })); + }, [remoteRows]); + + const remoteGoogleTimelineRows = useMemo(() => { + if (resolvedViewMode !== "jobs_and_google") { + return []; + } + if (!googleAgendaRows) { + return null; + } + + const now = Date.now(); + return googleAgendaRows.map((row) => ({ + lessonId: row.providerEventId, + source: "google" as const, + roleView: (role ?? "instructor") as "instructor" | "studio", + studioName: row.location ?? "Google Calendar", + sport: row.title, + startTime: row.startTime, + endTime: row.endTime, + status: row.status, + lifecycle: getLifecycle(row.status, now, row.startTime, row.endTime), + ...(row.isAllDay ? { isAllDay: true } : {}), + ...(row.location ? { location: row.location } : {}), + ...(row.htmlLink ? { htmlLink: row.htmlLink } : {}), + })); + }, [googleAgendaRows, resolvedViewMode, role]); + + const remoteCombinedRows = useMemo(() => { + if (!remoteJobTimelineRows) { + return null; + } + return [...remoteJobTimelineRows, ...(remoteGoogleTimelineRows ?? [])].sort( + (a, b) => + a.startTime - b.startTime || + a.endTime - b.endTime || + a.lessonId.localeCompare(b.lessonId), + ); + }, [remoteGoogleTimelineRows, remoteJobTimelineRows]); + + const remoteRowsSignature = useMemo( + () => (remoteCombinedRows ? buildTimelineRowsSignature(remoteCombinedRows) : ""), + [remoteCombinedRows], + ); + const lastPersistSignatureRef = useRef(""); + + const { cachedRows, cacheReady, persist } = useTimelineCache( + role, + startTime, + endTime, + resolvedViewMode, ); - const { cachedRows, cacheReady, persist } = useTimelineCache(role, startTime, endTime); useEffect(() => { - if (!remoteTimelineRows) return; - if (remoteRowsSignature === lastPersistSignatureRef.current) return; + if (!remoteCombinedRows) { + return; + } + if (remoteRowsSignature === lastPersistSignatureRef.current) { + return; + } lastPersistSignatureRef.current = remoteRowsSignature; - void persist(remoteTimelineRows); - }, [persist, remoteRowsSignature, remoteTimelineRows]); + void persist(remoteCombinedRows); + }, [persist, remoteCombinedRows, remoteRowsSignature]); const rows = useMemo(() => { - if (remoteTimelineRows) return remoteTimelineRows; - if (cachedRows) return cachedRows; + if (remoteCombinedRows) { + return remoteCombinedRows; + } + if (cachedRows) { + return cachedRows; + } return []; - }, [cachedRows, remoteTimelineRows]); + }, [cachedRows, remoteCombinedRows]); const filteredRows = useMemo(() => { const start = dayKeyToTimestamp(windowRange.start); @@ -225,9 +375,12 @@ export function useCalendarTabController({ locale }: { locale: string }) { }, [rows, windowRange.end, windowRange.start]); const syncEvents = useMemo(() => { - if (currentUser?.role !== "instructor") return []; - const staleCutoff = Date.now() - 7 * DAY_MS; - return rows + if (!role || !remoteJobTimelineRows) { + return []; + } + const now = Date.now(); + const staleCutoff = now - 7 * DAY_MS; + return remoteJobTimelineRows .filter((row) => row.status !== "cancelled" && row.endTime >= staleCutoff) .sort( (a, b) => @@ -242,7 +395,7 @@ export function useCalendarTabController({ locale }: { locale: string }) { endDate: new Date(row.endTime), notes: `Studio: ${row.studioName}`, })); - }, [currentUser?.role, rows]); + }, [remoteJobTimelineRows, role]); const appleSyncSignature = useMemo( () => @@ -253,98 +406,130 @@ export function useCalendarTabController({ locale }: { locale: string }) { .join("|"), [syncEvents], ); - const lastAppleSyncSignatureRef = useRef(""); + const lastAppleSyncSignatureRef = useRef(""); useEffect(() => { - if (currentUser?.role !== "instructor") return; - if (!instructorSettings || instructorSettings.calendarProvider !== "apple") return; - if (!instructorSettings.calendarSyncEnabled) return; - if (syncEvents.length === 0) return; - if (appleSyncSignature === lastAppleSyncSignatureRef.current) return; + if (!role) { + return; + } + if (!calendarSettings || calendarSettings.calendarProvider !== "apple") { + return; + } + if (!calendarSettings.calendarSyncEnabled) { + return; + } + if (syncEvents.length === 0) { + return; + } + if (appleSyncSignature === lastAppleSyncSignatureRef.current) { + return; + } lastAppleSyncSignatureRef.current = appleSyncSignature; void syncDeviceCalendarEvents(syncEvents); - }, [appleSyncSignature, currentUser?.role, instructorSettings, syncEvents]); + }, [appleSyncSignature, calendarSettings, role, syncEvents]); const lastGoogleSyncAtRef = useRef(0); - const googleSyncSignature = useMemo( - () => `${startTime}:${endTime}:${remoteRowsSignature}`, - [endTime, remoteRowsSignature, startTime], - ); - const lastGoogleSyncSignatureRef = useRef(""); useEffect(() => { - if (currentUser?.role !== "instructor") return; - if (!instructorSettings || instructorSettings.calendarProvider !== "google") return; - if (!instructorSettings.calendarSyncEnabled) return; - if (googleSyncSignature === lastGoogleSyncSignatureRef.current) return; + if (!role) { + return; + } + if (!googleStatus?.connected) { + return; + } + if (!calendarSettings || calendarSettings.calendarProvider !== "google") { + return; + } + if (!calendarSettings.calendarSyncEnabled && resolvedViewMode !== "jobs_and_google") { + return; + } const now = Date.now(); - if (now - lastGoogleSyncAtRef.current < 3 * 60 * 1000) return; + if (now - lastGoogleSyncAtRef.current < 3 * 60 * 1000) { + return; + } lastGoogleSyncAtRef.current = now; - lastGoogleSyncSignatureRef.current = googleSyncSignature; - void syncGoogleCalendar({ startTime, endTime, limit: 1000 }); + void syncGoogleCalendar({ + startTime, + endTime, + limit: 1000, + }); }, [ - currentUser?.role, + calendarSettings, endTime, - googleSyncSignature, - instructorSettings, + googleStatus?.connected, + resolvedViewMode, + role, startTime, syncGoogleCalendar, ]); - const lessonCountByDay = useMemo(() => { - const counts = new Map(); - for (const row of filteredRows) { - const dayKey = toDayKey(row.startTime); - counts.set(dayKey, (counts.get(dayKey) ?? 0) + 1); - } - return counts; - }, [filteredRows]); - - const { sections, sectionIndexByDay } = useMemo(() => { + const { listItems, dayStartIndexByKey } = useMemo(() => { const rowsByDay = new Map(); for (const row of filteredRows) { - const dayKey = toDayKey(row.startTime); - const existing = rowsByDay.get(dayKey); + const dk = toDayKey(row.startTime); + const existing = rowsByDay.get(dk); if (existing) { existing.push(row); } else { - rowsByDay.set(dayKey, [row]); + rowsByDay.set(dk, [row]); } } - const agendaDayKeys = Array.from(rowsByDay.keys()).sort(compareDayKey); - if (!rowsByDay.has(selectedDay)) { - agendaDayKeys.push(selectedDay); - agendaDayKeys.sort(compareDayKey); - } + const items: TimelineListItem[] = []; + const dayIndexMap = new Map(); + const days = enumerateDays(windowRange.start, windowRange.end); - if (agendaDayKeys.length === 0) { - agendaDayKeys.push(selectedDay); + for (const dk of days) { + dayIndexMap.set(dk, items.length); + items.push({ kind: "dayHeader", key: `${dk}:header`, dayKey: dk }); + const dayRows = rowsByDay.get(dk) ?? []; + if (dayRows.length === 0 && (dk === selectedDay || dk === todayKey)) { + items.push({ kind: "empty", key: `${dk}:empty`, dayKey: dk }); + } else { + for (const lesson of dayRows) { + items.push({ + kind: "lesson", + key: `${dk}:${lesson.source}:${lesson.lessonId}`, + dayKey: dk, + lesson, + }); + } + } } - const nextSections = agendaDayKeys.map((dayKey) => { - const dayRows = rowsByDay.get(dayKey) ?? []; - const data: AgendaItem[] = - dayRows.length > 0 - ? dayRows.map((lesson) => ({ - kind: "lesson", - key: `${dayKey}:${lesson.lessonId}`, - dayKey, - lesson, - })) - : [{ kind: "empty", key: `${dayKey}:empty`, dayKey }]; - - return { - key: dayKey, - dayKey, - data, - }; - }); + return { listItems: items, dayStartIndexByKey: dayIndexMap }; + }, [filteredRows, selectedDay, todayKey, windowRange.end, windowRange.start]); - return { - sections: nextSections, - sectionIndexByDay: new Map(nextSections.map((section, index) => [section.dayKey, index])), - }; - }, [filteredRows, selectedDay]); + const lessonCountByDay = useMemo(() => { + const counts = new Map(); + for (const row of rows) { + const dk = toDayKey(row.startTime); + counts.set(dk, (counts.get(dk) ?? 0) + 1); + } + return counts; + }, [rows]); + + const scrollToDay = useCallback( + (dayKey: string) => { + const index = dayStartIndexByKey.get(dayKey); + if (index === undefined) { + return; + } + programmaticScrollRef.current = true; + try { + listRef.current?.scrollToIndex({ + index, + animated: true, + viewPosition: 0, + }); + } catch { + /* layout not ready */ + } + setTimeout(() => { + programmaticScrollRef.current = false; + }, 500); + }, + [dayStartIndexByKey], + ); const ensureDayInWindow = useCallback((dayKey: string) => { setWindowRange((prev) => { @@ -356,104 +541,120 @@ export function useCalendarTabController({ locale }: { locale: string }) { if (compareDayKey(dayKey, prev.end) > 0) { nextEnd = addDays(dayKey, TIMELINE_EXTEND_BUFFER_DAYS); } - if (nextStart === prev.start && nextEnd === prev.end) return prev; + if (nextStart === prev.start && nextEnd === prev.end) { + return prev; + } return { start: nextStart, end: nextEnd }; }); }, []); - const scrollToDay = useCallback( - (dayKey: string, animated = true) => { - const sectionIndex = sectionIndexByDay.get(dayKey); - if (sectionIndex === undefined) { - return false; - } - try { - listRef.current?.scrollToLocation({ - sectionIndex, - itemIndex: 0, - animated, - viewOffset: 8, - }); - return true; - } catch { - return false; - } - }, - [sectionIndexByDay], - ); - - useEffect(() => { - const pendingRequest = pendingScrollRequestRef.current; - if (!pendingRequest) { - return; - } - if (!sectionIndexByDay.has(pendingRequest.dayKey)) { - return; - } + const viewabilityConfig = useRef({ + viewAreaCoveragePercentThreshold: 50, + }).current; - const task = InteractionManager.runAfterInteractions(() => { - const request = pendingScrollRequestRef.current; - if (!request) { + const onViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + if (programmaticScrollRef.current) { return; } - const didScroll = scrollToDay(request.dayKey, request.animated); - if (didScroll) { - pendingScrollRequestRef.current = null; + const now = Date.now(); + if (now - lastViewSyncAtRef.current < 180) { + return; + } + const firstHeader = viewableItems.find( + (viewableItem) => (viewableItem.item as TimelineListItem).kind === "dayHeader", + ); + if (!firstHeader) { + return; } - }); - return () => { - task.cancel(); - }; - }, [scrollToDay, sectionIndexByDay]); + const dayKey = (firstHeader.item as TimelineListItem).dayKey; + if (selectedDayRef.current === dayKey) { + return; + } + lastViewSyncAtRef.current = now; + selectedDayRef.current = dayKey; + setSelectedDay(dayKey); + }, + [], + ); const handleDayPress = useCallback( - (dayKey: string, options?: { animated?: boolean }) => { - const animated = options?.animated ?? true; + (dayKey: string) => { selectedDayRef.current = dayKey; setSelectedDay(dayKey); ensureDayInWindow(dayKey); - pendingScrollRequestRef.current = { - dayKey, - animated, - }; - if (!scrollToDay(dayKey, animated)) { - return; - } - pendingScrollRequestRef.current = null; + setTimeout(() => scrollToDay(dayKey), 50); }, [ensureDayInWindow, scrollToDay], ); const handleWeekChange = useCallback( (deltaWeeks: number) => { - handleDayPress(addDays(selectedDayRef.current, deltaWeeks * 7)); + const newDay = addDays(selectedDay, deltaWeeks * 7); + handleDayPress(newDay); }, - [handleDayPress], + [handleDayPress, selectedDay], ); const handleTodayPress = useCallback(() => { - handleDayPress(todayKey, { animated: false }); + handleDayPress(todayKey); }, [handleDayPress, todayKey]); - const firstDayOfWeek = useMemo(() => resolveFirstDayOfWeek(locale), [locale]); - const selectedWeekStart = useMemo( - () => getWeekStart(selectedDay, firstDayOfWeek), - [firstDayOfWeek, selectedDay], + const openMonthPicker = useCallback(() => { + setShowMonthPicker(true); + }, []); + + const handleMonthPickerChange = useCallback( + (_event: unknown, selectedDate?: Date) => { + if (Platform.OS !== "ios") { + setShowMonthPicker(false); + } + if (!selectedDate) { + return; + } + setShowMonthPicker(false); + handleDayPress(toDayKey(selectedDate.getTime())); + }, + [handleDayPress], ); - const selectedWeekDays = useMemo(() => getWeekDays(selectedWeekStart), [selectedWeekStart]); - const isLoading = currentUser === undefined || (!cacheReady && !remoteRows); + const overrideItemLayout = useCallback((layout: ItemLayout, item: TimelineListItem) => { + if (item.kind === "dayHeader") { + layout.size = ESTIMATED_DAY_HEADER_SIZE; + } else if (item.kind === "empty") { + layout.size = ESTIMATED_EMPTY_SIZE; + } else { + layout.size = ESTIMATED_LESSON_SIZE; + } + }, []); + + const isLoading = + currentUser === undefined || + !viewModeReady || + (!cacheReady && !remoteRows) || + (resolvedViewMode === "jobs_and_google" && googleAgendaRows === undefined && !cachedRows); return { selectedDay, - selectedWeekDays, + showMonthPicker, listRef, - sections, + listItems, lessonCountByDay, + viewabilityConfig, + onViewableItemsChanged, handleDayPress, handleWeekChange, handleTodayPress, + openMonthPicker, + handleMonthPickerChange, + overrideItemLayout, + selectedDayTimestamp: dayKeyToTimestamp(selectedDay), isLoading, + canShowGoogleAgenda, + viewMode: resolvedViewMode, + setViewMode, }; } + +export { dayKeyToTimestamp, toDayKey }; diff --git a/src/components/home/home-dashboard-layout.tsx b/src/components/home/home-dashboard-layout.tsx index eaec7a3e..4f0cf660 100644 --- a/src/components/home/home-dashboard-layout.tsx +++ b/src/components/home/home-dashboard-layout.tsx @@ -1,16 +1,12 @@ import type { PropsWithChildren } from "react"; -import { type StyleProp, Text, useWindowDimensions, View, type ViewStyle } from "react-native"; +import { type StyleProp, Text, View, type ViewStyle } from "react-native"; import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; - -const HOME_WIDE_BREAKPOINT = 1180; -const HOME_EXPANDED_BREAKPOINT = 1380; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; export function useHomeDashboardLayout() { - const { width } = useWindowDimensions(); - const isWideWeb = process.env.EXPO_OS === "web" && width >= HOME_WIDE_BREAKPOINT; - const isExpandedWeb = process.env.EXPO_OS === "web" && width >= HOME_EXPANDED_BREAKPOINT; + const { isDesktopWeb: isWideWeb, isExpandedWeb } = useLayoutBreakpoint(); return { isWideWeb, diff --git a/src/components/home/home-header-sheet.tsx b/src/components/home/home-header-sheet.tsx index 70140f24..27a9c3ee 100644 --- a/src/components/home/home-header-sheet.tsx +++ b/src/components/home/home-header-sheet.tsx @@ -1,21 +1,19 @@ -import { useEffect } from "react"; -import { Platform, Pressable, ScrollView, Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import Animated, { Extrapolation, interpolate, type SharedValue, useAnimatedStyle, } from "react-native-reanimated"; +import { TopSheetSurface } from "@/components/layout/top-sheet-surface"; import { KitFloatingBadge } from "@/components/ui/kit"; import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; import { BrandSpacing, BrandType } from "@/constants/brand"; -import { useSystemUi } from "@/contexts/system-ui-context"; -import { toSportLabel } from "@/convex/constants"; import { useAppInsets } from "@/hooks/use-app-insets"; -const SHEET_EXPANDED_CONTENT_HEIGHT = 168; -const SHEET_CONTRACTED_CONTENT_HEIGHT = 88; +const SHEET_EXPANDED_CONTENT_HEIGHT = 92; +const SHEET_CONTRACTED_CONTENT_HEIGHT = 82; const SHEET_CONTENT_GAP = BrandSpacing.md; export function getHomeHeaderExpandedHeight(safeTop: number) { @@ -23,53 +21,37 @@ export function getHomeHeaderExpandedHeight(safeTop: number) { } export function getHomeHeaderScrollTopPadding(safeTop: number) { - const automaticTopInset = Platform.OS === "ios" ? safeTop : 0; + const automaticTopInset = process.env.EXPO_OS === "ios" ? safeTop : 0; return getHomeHeaderExpandedHeight(safeTop) - automaticTopInset + SHEET_CONTENT_GAP; } // Scroll range over which the sheet transitions const SCROLL_EXPAND_START = 0; -const SCROLL_EXPAND_END = 100; +const SCROLL_EXPAND_END = 80; type HomeHeaderSheetProps = { displayName: string; + subtitle?: string; profileImageUrl?: string | null | undefined; scrollY: SharedValue; palette: BrandPalette; - statsLabel?: string; - statsValue?: string; - extraStatsLabel?: string; - extraStatsValue?: string; isVerified?: boolean; - sports?: string[] | undefined; onPressAvatar?: (() => void) | undefined; }; export function HomeHeaderSheet({ displayName, + subtitle, profileImageUrl, scrollY, palette, - statsLabel, - statsValue, - extraStatsLabel, - extraStatsValue, isVerified = false, - sports, onPressAvatar, }: HomeHeaderSheetProps) { - const { setTopInsetBackgroundColor } = useSystemUi(); const { safeTop } = useAppInsets(); const expandedHeight = getHomeHeaderExpandedHeight(safeTop); const contractedHeight = safeTop + SHEET_CONTRACTED_CONTENT_HEIGHT; - useEffect(() => { - setTopInsetBackgroundColor(palette.surfaceAlt); - return () => { - setTopInsetBackgroundColor(null); - }; - }, [palette.surfaceAlt, setTopInsetBackgroundColor]); - // 0 = expanded, 1 = contracted const animatedSheetStyle = useAnimatedStyle(() => { const contentHeight = interpolate( @@ -87,8 +69,8 @@ export function HomeHeaderSheet({ const identityWrapStyle = useAnimatedStyle(() => ({ opacity: interpolate( scrollY.value, - [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.72, SCROLL_EXPAND_END], - [1, 0.3, 0], + [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 1.5], + [1, 0.4], Extrapolation.CLAMP, ), transform: [ @@ -96,7 +78,15 @@ export function HomeHeaderSheet({ translateX: interpolate( scrollY.value, [SCROLL_EXPAND_START, SCROLL_EXPAND_END], - [0, -10], + [0, -6], + Extrapolation.CLAMP, + ), + }, + { + translateY: interpolate( + scrollY.value, + [SCROLL_EXPAND_START, SCROLL_EXPAND_END], + [0, -4], Extrapolation.CLAMP, ), }, @@ -107,13 +97,13 @@ export function HomeHeaderSheet({ const fontSize = interpolate( scrollY.value, [SCROLL_EXPAND_START, SCROLL_EXPAND_END], - [42, 30], + [32, 26], Extrapolation.CLAMP, ); const lineHeight = interpolate( scrollY.value, [SCROLL_EXPAND_START, SCROLL_EXPAND_END], - [52, 36], + [36, 30], Extrapolation.CLAMP, ); return { @@ -122,23 +112,23 @@ export function HomeHeaderSheet({ }; }); - const expandedSubtitleStyle = useAnimatedStyle(() => { + const subtitleStyle = useAnimatedStyle(() => { return { opacity: interpolate( scrollY.value, - [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.8], + [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.7], [1, 0], Extrapolation.CLAMP, ), - maxHeight: interpolate( + height: interpolate( scrollY.value, - [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.8], - [24, 0], + [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.7], + [18, 0], Extrapolation.CLAMP, ), marginTop: interpolate( scrollY.value, - [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.8], + [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.7], [2, 0], Extrapolation.CLAMP, ), @@ -146,30 +136,6 @@ export function HomeHeaderSheet({ }; }); - const expandedSportsStyle = useAnimatedStyle(() => { - return { - opacity: interpolate( - scrollY.value, - [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.75], - [1, 0], - Extrapolation.CLAMP, - ), - maxHeight: interpolate( - scrollY.value, - [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.75], - [44, 0], - Extrapolation.CLAMP, - ), - marginTop: interpolate( - scrollY.value, - [SCROLL_EXPAND_START, SCROLL_EXPAND_END * 0.75], - [12, 0], - Extrapolation.CLAMP, - ), - overflow: "hidden" as const, - }; - }); - const profileAvatarStyle = useAnimatedStyle(() => ({ transform: [ { @@ -186,8 +152,9 @@ export function HomeHeaderSheet({ const bg = palette.surfaceAlt as string; return ( - - {/* Expanded Stats Subtitle */} - {(statsLabel && statsValue) || (extraStatsLabel && extraStatsValue) ? ( - - {statsLabel && statsValue ? ( - <> - - {statsValue} - - - {statsLabel} - - - ) : null} - {extraStatsLabel && extraStatsValue ? ( - <> - - {"*"} - - - {extraStatsValue} - - - {extraStatsLabel} - - - ) : null} - - ) : null} - - {/* Sports List (Expanded State Only) */} - {sports && sports.length > 0 && ( - - + - {sports.map((sport, index) => ( - - - {toSportLabel(sport as never)} - - {index < sports.length - 1 && ( - - )} - - ))} - + {subtitle} + - )} + ) : null} - + ); } diff --git a/src/components/home/home-screen.tsx b/src/components/home/home-screen.tsx index b249e30a..503458c3 100644 --- a/src/components/home/home-screen.tsx +++ b/src/components/home/home-screen.tsx @@ -105,14 +105,12 @@ export default function HomeScreen() { locale={locale} openMatches={instructorHomeStats.openMatches} pendingApplications={instructorHomeStats.pendingApplications} - totalEarningsAgorot={instructorHomeStats.totalEarningsAgorot} palette={palette} currencyFormatter={currencyFormatter} t={t} earningsEvents={instructorHomeStats.earningsEvents} lessonEvents={instructorHomeStats.lessonEvents} upcomingSessions={instructorHomeStats.upcomingSessions} - sports={instructorSettings?.sports} onOpenJobs={() => router.push(INSTRUCTOR_JOBS_ROUTE)} onOpenProfile={() => router.push(INSTRUCTOR_PROFILE_ROUTE)} /> @@ -145,7 +143,6 @@ export default function HomeScreen() { t={t} recentJobs={studioJobs} jobsFilled={jobsFilled} - sports={studioSettings?.sports} onOpenJobs={() => router.push(STUDIO_JOBS_ROUTE)} onOpenCalendar={() => router.push(STUDIO_CALENDAR_ROUTE)} onOpenProfile={() => router.push(STUDIO_PROFILE_ROUTE)} diff --git a/src/components/home/home-shared.tsx b/src/components/home/home-shared.tsx index 44844271..76dde1b4 100644 --- a/src/components/home/home-shared.tsx +++ b/src/components/home/home-shared.tsx @@ -1,10 +1,7 @@ -import { View } from "react-native"; -import Animated, { FadeInUp } from "react-native-reanimated"; +import { Text, View } from "react-native"; import { ThemedText } from "@/components/themed-text"; -import { AppSymbol } from "@/components/ui/app-symbol"; -import { KitList, KitListItem } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandSpacing } from "@/constants/brand"; +import { BrandRadius, BrandType } from "@/constants/brand"; import type { JobStatus } from "@/lib/status-tokens"; import { getJobStatusTokens } from "@/lib/status-tokens"; @@ -58,11 +55,6 @@ export function getRelativeTimeLabel(targetTime: number, now: number, locale: st return fmt(deltaDays, "day", deltaDays); } -function formatCount(value: number): string { - if (value >= 100) return "99+"; - return String(value); -} - type StatusPillProps = { label: string; status: JobStatus | "upcoming"; @@ -94,122 +86,82 @@ export function StatusPill({ label, status, palette }: StatusPillProps) { ); } -type HeroMetricProps = { +// ─── Jobs-list shared components ────────────────────────────────────────────── + +type DotStatusPillProps = { + backgroundColor: string; + color: string; label: string; - value: number; - palette: BrandPalette; }; -function HeroMetric({ label, value, palette }: HeroMetricProps) { +/** Colored-dot status pill used in job cards. */ +export function DotStatusPill({ backgroundColor, color, label }: DotStatusPillProps) { return ( - - + + - {formatCount(value)} - - {label} - + ); } -type HeroBlockProps = { - title: string; - subtitle: string; - palette: BrandPalette; - metrics: { label: string; value: number }[]; -}; - -export function HeroBlock({ title, subtitle, palette, metrics }: HeroBlockProps) { - return ( - - - - {subtitle} - - - {title} - - - - - {metrics.map((metric, index) => ( - - - {index < metrics.length - 1 ? ( - - ) : null} - - ))} - - - ); -} - -type PrimaryActionCardProps = { - title: string; - subtitle: string; - icon: "briefcase.fill" | "calendar.circle.fill"; - onPress: () => void; +type MetricCellProps = { + align?: "flex-start" | "flex-end"; + label: string; + value: string; palette: BrandPalette; }; -export function PrimaryActionCard({ - title, - subtitle, - icon, - onPress, - palette, -}: PrimaryActionCardProps) { +/** Label + value metric pair used in job cards. */ +export function MetricCell({ align = "flex-start", label, value, palette }: MetricCellProps) { return ( - - - - - - } - accessory={} - onPress={onPress} - > - - {subtitle} - - - - + + + {label} + + + {value} + + ); } diff --git a/src/components/home/home-stats-row.tsx b/src/components/home/home-stats-row.tsx new file mode 100644 index 00000000..67c263d0 --- /dev/null +++ b/src/components/home/home-stats-row.tsx @@ -0,0 +1,56 @@ +import { ScrollView, Text } from "react-native"; +import { HomeSurface } from "@/components/home/home-dashboard-layout"; +import type { BrandPalette } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; + +type StatItem = { + label: string; + value: string; +}; + +type HomeStatsRowProps = { + palette: BrandPalette; + stats: StatItem[]; +}; + +export function HomeStatsRow({ palette, stats }: HomeStatsRowProps) { + return ( + + {stats.map((stat) => ( + + + {stat.label} + + + {stat.value} + + + ))} + + ); +} diff --git a/src/components/home/instructor-home-content.tsx b/src/components/home/instructor-home-content.tsx index 2f0be1c9..1725b763 100644 --- a/src/components/home/instructor-home-content.tsx +++ b/src/components/home/instructor-home-content.tsx @@ -1,31 +1,33 @@ import type { TFunction } from "i18next"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { Text, View } from "react-native"; -import Animated, { FadeInUp, useAnimatedRef } from "react-native-reanimated"; +import Animated, { FadeInUp, useAnimatedRef, useScrollViewOffset } from "react-native-reanimated"; import { HomeSectionHeading, HomeSurface, useHomeDashboardLayout, } from "@/components/home/home-dashboard-layout"; +import { + getHomeHeaderScrollTopPadding, + HomeHeaderSheet, +} from "@/components/home/home-header-sheet"; import { getRelativeTimeLabel } from "@/components/home/home-shared"; +import { HomeStatsRow } from "@/components/home/home-stats-row"; import { - getAdjacentTimeframe, getTimeframeData, type MetricMode, type Timeframe, } from "@/components/home/performance-chart-math"; import { PerformanceHeroCard, - type PerformanceMetricOption, - type PerformanceTimeframeOption, type PerformanceTimeframeSeries, } from "@/components/home/performance-hero-card"; +import { usePerformanceChart } from "@/components/home/use-performance-chart"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; -import { KitButton, KitPressable } from "@/components/ui/kit"; -import { ProfileAvatar } from "@/components/ui/profile-avatar"; +import { KitButton } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; import { getZoneLabel } from "@/constants/zones"; import { toSportLabel } from "@/convex/constants"; import { useAppInsets } from "@/hooks/use-app-insets"; @@ -56,14 +58,13 @@ type InstructorHomeContentProps = { locale: string; openMatches: number; pendingApplications: number; - totalEarningsAgorot: number; palette: BrandPalette; currencyFormatter: Intl.NumberFormat; t: TFunction; earningsEvents: InstructorPaymentRow[]; lessonEvents: InstructorApplicationRow[]; upcomingSessions: UpcomingSession[]; - sports: string[] | undefined; + onOpenJobs: () => void; onOpenProfile: () => void; }; @@ -75,170 +76,125 @@ export function InstructorHomeContent({ locale, openMatches, pendingApplications, - totalEarningsAgorot, palette, currencyFormatter, t, earningsEvents, lessonEvents, upcomingSessions, - sports, onOpenJobs, onOpenProfile, }: InstructorHomeContentProps) { - void totalEarningsAgorot; const now = useMemo(() => Date.now(), []); const zoneLanguage = locale.toLowerCase().startsWith("he") ? "he" : "en"; const { safeTop } = useAppInsets(); const layout = useHomeDashboardLayout(); const scrollRef = useAnimatedRef(); - const [timeframe, setTimeframe] = useState("weekly"); - const [metricMode, setMetricMode] = useState("earnings"); + const scrollY = useScrollViewOffset(scrollRef); - const timeframeSeries = useMemo(() => { - const frames = {} as Record; + const computeSeries = useMemo(() => { + return (currentMetricMode: MetricMode) => { + const frames = {} as Record; - (["weekly", "monthly", "yearly"] as const).forEach((frame) => { - const frameData = getTimeframeData(frame, now, locale); - const values = Array.from({ length: frameData.bucketStarts.length }, () => 0); + (["weekly", "monthly", "yearly"] as const).forEach((frame) => { + const frameData = getTimeframeData(frame, now, locale); + const values = Array.from({ length: frameData.bucketStarts.length }, () => 0); - if (metricMode === "earnings") { - for (const row of earningsEvents) { - for (let index = 0; index < frameData.bucketStarts.length; index += 1) { - const bucketStart = frameData.bucketStarts[index]!; - const bucketEnd = frameData.bucketEnds[index]!; - if (row.timestamp >= bucketStart && row.timestamp < bucketEnd) { - values[index] = (values[index] ?? 0) + row.amountAgorot; - break; + if (currentMetricMode === "earnings") { + for (const row of earningsEvents) { + for (let index = 0; index < frameData.bucketStarts.length; index += 1) { + const bucketStart = frameData.bucketStarts[index]!; + const bucketEnd = frameData.bucketEnds[index]!; + if (row.timestamp >= bucketStart && row.timestamp < bucketEnd) { + values[index] = (values[index] ?? 0) + row.amountAgorot; + break; + } } } - } - } else { - for (const row of lessonEvents) { - for (let index = 0; index < frameData.bucketStarts.length; index += 1) { - const bucketStart = frameData.bucketStarts[index]!; - const bucketEnd = frameData.bucketEnds[index]!; - if (row.endTime >= bucketStart && row.endTime < bucketEnd) { - values[index] = (values[index] ?? 0) + 1; - break; + } else { + for (const row of lessonEvents) { + for (let index = 0; index < frameData.bucketStarts.length; index += 1) { + const bucketStart = frameData.bucketStarts[index]!; + const bucketEnd = frameData.bucketEnds[index]!; + if (row.endTime >= bucketStart && row.endTime < bucketEnd) { + values[index] = (values[index] ?? 0) + 1; + break; + } } } } - } - frames[frame] = { - values, - axisTicks: frameData.axisTicks, - }; - }); + frames[frame] = { + values, + axisTicks: frameData.axisTicks, + }; + }); - return frames; - }, [earningsEvents, lessonEvents, metricMode, locale, now]); + return frames; + }; + }, [earningsEvents, lessonEvents, locale, now]); + + const chart = usePerformanceChart({ + computeSeries, + currencyFormatter, + metricLabels: { + earnings: t("home.performance.earnings", { defaultValue: "Earnings" }), + lessons: t("home.performance.lessonLabel", { defaultValue: "Lessons" }), + }, + t, + }); - const frameTotal = timeframeSeries[timeframe].values.reduce((sum, value) => sum + value, 0); - const timeframeLabel = t(`home.performance.${timeframe}`); - const summaryValue = - metricMode === "earnings" - ? currencyFormatter.format(frameTotal / 100) - : `${String(frameTotal)} ${t("home.performance.lessons")}`; - const timeframeOptions = useMemo( - () => [ - { value: "weekly", label: t("home.performance.weekly") }, - { value: "monthly", label: t("home.performance.monthly") }, - { value: "yearly", label: t("home.performance.yearly") }, - ], - [t], - ); - const metricOptions = useMemo( - () => [ - { value: "earnings", label: "Earnings" }, - { value: "lessons", label: "Lessons" }, - ], - [], - ); const nextSession = upcomingSessions[0] ?? null; - const readinessLabel = isVerified ? "Verified and ready" : "Needs profile polish"; + const readinessLabel = isVerified + ? t("home.instructor.verified", { defaultValue: "Verified and ready" }) + : t("home.instructor.needsPolish", { defaultValue: "Polish your profile" }); const heroTitle = nextSession - ? `${toSportLabel(nextSession.sport as never)} at ${nextSession.studioName}` - : `${String(openMatches)} open matches near you`; - const heroSubtitle = nextSession - ? [ - formatDateTime(nextSession.startTime, locale), - getZoneLabel(nextSession.zone, zoneLanguage), - currencyFormatter.format(nextSession.pay), - ].join(" · ") - : "Fresh sessions are moving on the board right now."; - const heroSecondaryLabel = pendingApplications > 0 ? "Pending applications" : "Ready state"; + ? t("home.instructor.heroSession", { + sport: toSportLabel(nextSession.sport as never), + studio: nextSession.studioName, + defaultValue: `${toSportLabel(nextSession.sport as never)} at ${nextSession.studioName}`, + }) + : t("home.instructor.heroMatches", { + count: openMatches, + defaultValue: `${String(openMatches)} open matches near you`, + }); + const heroSecondaryLabel = + pendingApplications > 0 + ? t("home.instructor.pendingApps", { defaultValue: "Pending applications" }) + : t("home.instructor.readyState", { defaultValue: "Ready state" }); const heroSecondaryValue = pendingApplications > 0 - ? `${String(pendingApplications)} waiting` + ? t("home.instructor.waitingCount", { + count: pendingApplications, + defaultValue: `${String(pendingApplications)} waiting`, + }) : nextSession ? getRelativeTimeLabel(nextSession.startTime, now, locale) - : "Profile set"; + : t("home.instructor.profileSet", { defaultValue: "Profile set" }); const visibleSessions = upcomingSessions.slice(0, layout.isWideWeb ? 6 : 4); return ( + - - - - {readinessLabel} - - - {displayName} - - {sports && sports.length > 0 ? ( - - {sports - .slice(0, 3) - .map((sport) => toSportLabel(sport as never)) - .join(" · ")} - - ) : null} - - - - - - + {/* Collapsed Hero Card */} - - - - {nextSession ? "NEXT LESSON" : "JOBS BOARD"} - - - {heroTitle} - - - {heroSubtitle} - - - - - - {heroSecondaryLabel} - - - {heroSecondaryValue} - - - - - - - - {readinessLabel} - - {sports && sports.length > 0 ? ( - - {sports - .slice(0, 4) - .map((sport) => toSportLabel(sport as never)) - .join(" · ")} - - ) : null} - - + + {nextSession + ? t("home.instructor.eyebrowNext", { defaultValue: "NEXT LESSON" }) + : t("home.instructor.eyebrowBoard", { defaultValue: "JOBS BOARD" })} + + - - - + {heroTitle} + + + + {/* Inline Stats Row */} + + + { - setTimeframe((prev) => getAdjacentTimeframe(prev, direction)); - }} + timeframe={chart.timeframe} + metricMode={chart.metricMode} + timeframeLabel={chart.timeframeLabel} + insightLabel={chart.insightLabel} + totalLabel={chart.summaryValue} + metricOptions={chart.metricOptions} + timeframeOptions={chart.timeframeOptions} + seriesByTimeframe={chart.seriesByTimeframe} + onSelectMetric={chart.setMetricMode} + onSelectTimeframe={chart.setTimeframe} + onSwipeTimeframe={chart.handleSwipeTimeframe} /> @@ -368,7 +298,7 @@ export function InstructorHomeContent({ {upcomingSessions.length === 0 ? ( @@ -377,7 +307,9 @@ export function InstructorHomeContent({ {t("home.instructor.noUpcoming")} - The jobs board is still live when you want the next one. + {t("home.instructor.emptySchedule", { + defaultValue: "The jobs board is live when you want the next one.", + })} ) : ( @@ -414,6 +346,7 @@ export function InstructorHomeContent({ }} > `hero-fill-${Math.random().toString(36).slice(2)}`, []); + const gradientId = `hero-fill-${useId()}`; const currentSeries = seriesByTimeframe[timeframe]; const currentMetricLabel = @@ -88,35 +90,6 @@ export function PerformanceHeroCard({ return labels; }, [chartWidth, currentSeries.axisTicks, pointXs]); - const insightLabel = useMemo(() => { - const values = currentSeries.values; - if (values.length === 0 || values.every((value) => value === 0)) { - return "No activity yet"; - } - - let peakIndex = 0; - let peakValue = values[0] ?? 0; - values.forEach((value, index) => { - if (value > peakValue) { - peakValue = value; - peakIndex = index; - } - }); - - const peakTick = - currentSeries.axisTicks.reduce((closest, tick) => { - if (closest === null) { - return tick; - } - return Math.abs(tick.index - peakIndex) < Math.abs(closest.index - peakIndex) - ? tick - : closest; - }, null) ?? null; - - const activePoints = values.filter((value) => value > 0).length; - return `Peak ${peakTick?.label ?? `#${String(peakIndex + 1)}`} · ${String(activePoints)} active`; - }, [currentSeries.axisTicks, currentSeries.values]); - const panResponder = useMemo( () => PanResponder.create({ diff --git a/src/components/home/studio-home-content.tsx b/src/components/home/studio-home-content.tsx index 559938fe..1ac078a3 100644 --- a/src/components/home/studio-home-content.tsx +++ b/src/components/home/studio-home-content.tsx @@ -1,7 +1,7 @@ import type { TFunction } from "i18next"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { Text, View } from "react-native"; -import Animated, { FadeInUp, useAnimatedRef } from "react-native-reanimated"; +import Animated, { FadeInUp, useAnimatedRef, useScrollViewOffset } from "react-native-reanimated"; import { HomeSectionHeading, @@ -9,22 +9,24 @@ import { useHomeDashboardLayout, } from "@/components/home/home-dashboard-layout"; import { - getAdjacentTimeframe, + getHomeHeaderScrollTopPadding, + HomeHeaderSheet, +} from "@/components/home/home-header-sheet"; +import { HomeStatsRow } from "@/components/home/home-stats-row"; +import { getTimeframeData, type MetricMode, type Timeframe, } from "@/components/home/performance-chart-math"; import { PerformanceHeroCard, - type PerformanceMetricOption, - type PerformanceTimeframeOption, type PerformanceTimeframeSeries, } from "@/components/home/performance-hero-card"; +import { usePerformanceChart } from "@/components/home/use-performance-chart"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { KitButton, KitPressable } from "@/components/ui/kit"; -import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; import { getZoneLabel } from "@/constants/zones"; import { toSportLabel } from "@/convex/constants"; import { useAppInsets } from "@/hooks/use-app-insets"; @@ -52,7 +54,6 @@ type StudioHomeContentProps = { currencyFormatter: Intl.NumberFormat; t: TFunction; recentJobs: RecentJob[]; - sports: string[] | undefined; onOpenJobs: () => void; onOpenCalendar: () => void; onOpenProfile: () => void; @@ -69,7 +70,6 @@ export function StudioHomeContent({ currencyFormatter, t, recentJobs, - sports, onOpenJobs, onOpenCalendar, onOpenProfile, @@ -81,153 +81,106 @@ export function StudioHomeContent({ .filter((job) => job.pendingApplicationsCount > 0) .slice(0, 4); const scrollRef = useAnimatedRef(); + const scrollY = useScrollViewOffset(scrollRef); const now = useMemo(() => Date.now(), []); - const [timeframe, setTimeframe] = useState("weekly"); - const [metricMode, setMetricMode] = useState("earnings"); - const timeframeSeries = useMemo(() => { - const frames = {} as Record; + const computeSeries = useMemo(() => { + return (currentMetricMode: MetricMode) => { + const frames = {} as Record; - (["weekly", "monthly", "yearly"] as const).forEach((frame) => { - const frameData = getTimeframeData(frame, now, locale); - const values = Array.from({ length: frameData.bucketStarts.length }, () => 0); + (["weekly", "monthly", "yearly"] as const).forEach((frame) => { + const frameData = getTimeframeData(frame, now, locale); + const values = Array.from({ length: frameData.bucketStarts.length }, () => 0); - for (const row of recentJobs) { - const metricValue = - metricMode === "earnings" - ? row.status === "cancelled" - ? 0 - : row.pay * 100 - : row.status === "completed" - ? 1 - : 0; - if (metricValue === 0) { - continue; - } + for (const row of recentJobs) { + const metricValue = + currentMetricMode === "earnings" + ? row.status === "cancelled" + ? 0 + : row.pay * 100 + : row.status === "completed" + ? 1 + : 0; + if (metricValue === 0) continue; - const metricTime = metricMode === "earnings" ? row.startTime : row.endTime; - for (let index = 0; index < frameData.bucketStarts.length; index += 1) { - const bucketStart = frameData.bucketStarts[index]!; - const bucketEnd = frameData.bucketEnds[index]!; - if (metricTime >= bucketStart && metricTime < bucketEnd) { - values[index] = (values[index] ?? 0) + metricValue; - break; + const metricTime = currentMetricMode === "earnings" ? row.startTime : row.endTime; + for (let index = 0; index < frameData.bucketStarts.length; index += 1) { + const bucketStart = frameData.bucketStarts[index]!; + const bucketEnd = frameData.bucketEnds[index]!; + if (metricTime >= bucketStart && metricTime < bucketEnd) { + values[index] = (values[index] ?? 0) + metricValue; + break; + } } } - } - frames[frame] = { - values, - axisTicks: frameData.axisTicks, - }; - }); + frames[frame] = { + values, + axisTicks: frameData.axisTicks, + }; + }); - return frames; - }, [metricMode, recentJobs, now, locale]); + return frames; + }; + }, [recentJobs, locale, now]); + + const chart = usePerformanceChart({ + computeSeries, + currencyFormatter, + metricLabels: { + earnings: t("home.performance.spend", { defaultValue: "Spend" }), + lessons: t("home.performance.sessions", { defaultValue: "Sessions" }), + }, + t, + }); - const frameTotal = timeframeSeries[timeframe].values.reduce((sum, value) => sum + value, 0); - const timeframeLabel = t(`home.performance.${timeframe}`); - const summaryValue = - metricMode === "earnings" - ? currencyFormatter.format(frameTotal / 100) - : `${String(frameTotal)} ${t("home.performance.lessons")}`; - const timeframeOptions = useMemo( - () => [ - { value: "weekly", label: t("home.performance.weekly") }, - { value: "monthly", label: t("home.performance.monthly") }, - { value: "yearly", label: t("home.performance.yearly") }, - ], - [t], - ); - const metricOptions = useMemo( - () => [ - { value: "earnings", label: "Spend" }, - { value: "lessons", label: "Sessions" }, - ], - [], - ); - const priorityJob = jobsNeedingReview[0] ?? recentJobs[0] ?? null; const heroTitle = jobsNeedingReview.length > 0 - ? "Decisions are waiting" - : `${String(openJobs)} active jobs on the board`; - const heroSubtitle = priorityJob - ? [ - toSportLabel(priorityJob.sport as never), - formatDateTime(priorityJob.startTime, locale), - getZoneLabel(priorityJob.zone, zoneLanguage), - ].join(" · ") - : "Hiring, scheduling, and payout flow stay in one lane here."; + ? t("home.studio.heroReview", { defaultValue: "Decisions are waiting" }) + : t("home.studio.heroActive", { + count: openJobs, + defaultValue: `${String(openJobs)} active jobs on the board`, + }); + const heroSecondaryLabel = - jobsNeedingReview.length > 0 ? "Pending applicants" : "Recently filled"; + jobsNeedingReview.length > 0 + ? t("home.studio.pendingApplicants", { defaultValue: "Pending applicants" }) + : t("home.studio.recentlyFilled", { defaultValue: "Recently filled" }); + const heroSecondaryValue = jobsNeedingReview.length > 0 - ? `${String(pendingApplicants)} waiting` - : `${String(jobsFilled)} closed`; + ? t("home.studio.waitingCount", { + count: pendingApplicants, + defaultValue: `${String(pendingApplicants)} waiting`, + }) + : t("home.studio.closedCount", { + count: jobsFilled, + defaultValue: `${String(jobsFilled)} closed`, + }); + const visibleRecentJobs = recentJobs.slice(0, layout.isWideWeb ? 6 : 4); return ( + - - - - {jobsNeedingReview.length > 0 ? "Needs review now" : "Studio command"} - - - {displayName} - - {sports && sports.length > 0 ? ( - - {sports - .slice(0, 3) - .map((sport) => toSportLabel(sport as never)) - .join(" · ")} - - ) : null} - - - - - - + {/* Collapsed Hero Card */} - - - - {jobsNeedingReview.length > 0 ? "REVIEW QUEUE" : "OPERATIONS"} - - - {heroTitle} - - - {heroSubtitle} - - - - - - {heroSecondaryLabel} - - - {heroSecondaryValue} - - - - - - - - {jobsNeedingReview.length > 0 - ? "Review applicants first, then lock the schedule." - : "Calendar stays one tap away when you need to place the next session."} - - - + + {jobsNeedingReview.length > 0 + ? t("home.studio.eyebrowReview", { defaultValue: "REVIEW QUEUE" }) + : t("home.studio.eyebrowOps", { defaultValue: "OPERATIONS" })} + + - - - + {heroTitle} + + 0 + ? t("home.actions.jobsTitle", { defaultValue: "Open Jobs" }) + : t("home.actions.calendarTitle", { defaultValue: "Calendar" }) + } + onPress={jobsNeedingReview.length > 0 ? onOpenJobs : onOpenCalendar} + size="sm" + /> + + {/* Inline Stats Row */} + + + { - setTimeframe((prev) => getAdjacentTimeframe(prev, direction)); - }} + timeframe={chart.timeframe} + metricMode={chart.metricMode} + timeframeLabel={chart.timeframeLabel} + insightLabel={chart.insightLabel} + totalLabel={chart.summaryValue} + metricOptions={chart.metricOptions} + timeframeOptions={chart.timeframeOptions} + seriesByTimeframe={chart.seriesByTimeframe} + onSelectMetric={chart.setMetricMode} + onSelectTimeframe={chart.setTimeframe} + onSwipeTimeframe={chart.handleSwipeTimeframe} /> @@ -360,7 +293,11 @@ export function StudioHomeContent({ entering={FadeInUp.delay(180).duration(320)} style={{ flex: layout.isWideWeb ? 1.08 : undefined, gap: 12 }} > - + {jobsNeedingReview.map((job, index) => ( {recentJobs.length === 0 ? ( @@ -435,7 +373,9 @@ export function StudioHomeContent({ {t("home.studio.noRecent")} - Post a shift and start filling your upcoming schedule. + {t("home.studio.emptyBoard", { + defaultValue: "Post a shift to start filling your schedule.", + })} ) : ( @@ -471,6 +411,7 @@ export function StudioHomeContent({ Record; + currencyFormatter: Intl.NumberFormat; + metricLabels: { earnings: string; lessons: string }; + t: TFunction; +}; + +/** + * Shared hook that manages performance chart state (timeframe, metric mode, swipe) + * and derives formatted labels from the caller-provided series data. + * + * The caller provides a `computeSeries` callback that takes the current `MetricMode` + * and computes the series. This allows the hook to own the `metricMode` state without + * crashing from circular dependencies. + */ +export function usePerformanceChart({ + computeSeries, + currencyFormatter, + metricLabels, + t, +}: UsePerformanceChartOptions) { + const [timeframe, setTimeframe] = useState("weekly"); + const [metricMode, setMetricMode] = useState("earnings"); + + const seriesByTimeframe = useMemo( + () => computeSeries(metricMode), + [computeSeries, metricMode], + ); + + const currentSeries = seriesByTimeframe[timeframe]; + const frameTotal = currentSeries.values.reduce((sum, value) => sum + value, 0); + + const timeframeLabel = t(`home.performance.${timeframe}`); + + const insightLabel = useMemo(() => { + const values = currentSeries.values; + if (values.length === 0 || values.every((value) => value === 0)) { + return t("home.performance.noActivity", { defaultValue: "No activity yet" }); + } + + let peakIndex = 0; + let peakValue = values[0] ?? 0; + values.forEach((value, index) => { + if (value > peakValue) { + peakValue = value; + peakIndex = index; + } + }); + + const peakTick = + currentSeries.axisTicks.reduce((closest, tick) => { + if (closest === null) return tick; + return Math.abs(tick.index - peakIndex) < Math.abs(closest.index - peakIndex) + ? tick + : closest; + }, null) ?? null; + + const activePoints = values.filter((value) => value > 0).length; + const peakStr = peakTick?.label ?? `#${String(peakIndex + 1)}`; + + return t("home.performance.peakActivity", { + peak: peakStr, + active: activePoints, + defaultValue: `Peak ${peakStr} · ${String(activePoints)} active`, + }); + }, [currentSeries.axisTicks, currentSeries.values, t]); + + const summaryValue = + metricMode === "earnings" + ? currencyFormatter.format(frameTotal / 100) + : `${String(frameTotal)} ${t("home.performance.lessons")}`; + + const timeframeOptions = useMemo( + () => [ + { value: "weekly", label: t("home.performance.weekly") }, + { value: "monthly", label: t("home.performance.monthly") }, + { value: "yearly", label: t("home.performance.yearly") }, + ], + [t], + ); + + const metricOptions = useMemo( + () => [ + { value: "earnings", label: metricLabels.earnings }, + { value: "lessons", label: metricLabels.lessons }, + ], + [metricLabels.earnings, metricLabels.lessons], + ); + + const handleSwipeTimeframe = useMemo( + () => (direction: "inc" | "dec") => { + setTimeframe((prev) => getAdjacentTimeframe(prev, direction)); + }, + [], + ); + + return { + timeframe, + setTimeframe, + metricMode, + setMetricMode, + seriesByTimeframe, + frameTotal, + timeframeLabel, + insightLabel, + summaryValue, + timeframeOptions, + metricOptions, + handleSwipeTimeframe, + }; +} diff --git a/src/components/jobs/instructor-feed.tsx b/src/components/jobs/instructor-feed.tsx index 65688542..876227e0 100644 --- a/src/components/jobs/instructor-feed.tsx +++ b/src/components/jobs/instructor-feed.tsx @@ -2,24 +2,25 @@ import { useMutation, useQuery } from "convex/react"; import { Redirect } from "expo-router"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, StyleSheet, Text, useWindowDimensions, View } from "react-native"; +import { Platform, StyleSheet, Text, View } from "react-native"; import { InstructorOpenJobsList } from "@/components/jobs/instructor/instructor-open-jobs-list"; import { NoticeBanner } from "@/components/jobs/notice-banner"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { LoadingScreen } from "@/components/loading-screen"; +import { ThemedText } from "@/components/themed-text"; import { EmptyState } from "@/components/ui/empty-state"; -import { KitChip } from "@/components/ui/kit"; +import { KitChip, KitSurface } from "@/components/ui/kit"; import { NativeSearchField } from "@/components/ui/native-search-field"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { getZoneLabel } from "@/constants/zones"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; +import { useAppInsets } from "@/hooks/use-app-insets"; import { useBrand } from "@/hooks/use-brand"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; -const WIDE_WEB_BREAKPOINT = 1180; - function SectionHeader({ title, subtitle, @@ -31,17 +32,12 @@ function SectionHeader({ }) { return ( - + {title} - - {subtitle} + + + {subtitle} + ); } @@ -49,10 +45,11 @@ function SectionHeader({ export function InstructorFeed() { const { t, i18n } = useTranslation(); const palette = useBrand(); - const { width } = useWindowDimensions(); + const { safeTop } = useAppInsets(); const locale = i18n.resolvedLanguage ?? "en"; const zoneLanguage = locale.toLowerCase().startsWith("he") ? "he" : "en"; - const isWideWeb = Platform.OS === "web" && width >= WIDE_WEB_BREAKPOINT; + const { isDesktopWeb: isWideWeb } = useLayoutBreakpoint(); + const mobileContentPaddingTop = Platform.OS === "android" ? safeTop + BrandSpacing.sm : 0; const [jobsSearchQuery, setJobsSearchQuery] = useState(""); const [jobsWindowFilter, setJobsWindowFilter] = useState<"all" | "24h" | "72h">("all"); @@ -95,7 +92,9 @@ export function InstructorFeed() { return ; } - const jobs = availableJobs ?? []; + type AvailableJob = NonNullable[number]; + + const jobs = (availableJobs ?? []) as AvailableJob[]; const hotNowCount = jobs.filter((job) => job.startTime <= now + 24 * 60 * 60 * 1000).length; const pendingCount = jobs.filter((job) => job.applicationStatus === "pending").length; const acceptedCount = jobs.filter((job) => job.applicationStatus === "accepted").length; @@ -118,8 +117,13 @@ export function InstructorFeed() { setApplyingJobId(jobId); try { await applyToJob({ jobId }); - } catch { - setApplyErrorMessage(t("jobsTab.applyError", { defaultValue: "Couldn't apply. Try again." })); + } catch (error) { + console.error("[jobs] apply failed", error); + setApplyErrorMessage( + t("jobsTab.actions.applyError", { + defaultValue: "Couldn't apply right now. Please try again.", + }), + ); } finally { setApplyingJobId(null); } @@ -135,7 +139,6 @@ export function InstructorFeed() { return ( Instructor queue @@ -208,8 +210,7 @@ export function InstructorFeed() { style={{ ...BrandType.micro, color: palette.onPrimary as string, - letterSpacing: 0.7, - textTransform: "uppercase", + letterSpacing: 0.2, }} > {label} @@ -250,8 +251,7 @@ export function InstructorFeed() { style={{ ...BrandType.micro, color: palette.textMuted as string, - letterSpacing: 1, - textTransform: "uppercase", + letterSpacing: 0.2, }} > {metric.label} @@ -396,21 +396,31 @@ export function InstructorFeed() { return ( - - + + + {[ @@ -421,14 +431,23 @@ export function InstructorFeed() { value: String(acceptedCount), accent: palette.success as string, }, - ].map((item, index) => ( - + ].map((item) => ( + {item.label} @@ -442,21 +461,10 @@ export function InstructorFeed() { > {item.value} - {index < 2 ? ( - - ) : null} ))} + - + {jobs.length === 0 ? ( diff --git a/src/components/jobs/instructor/instructor-open-jobs-list.tsx b/src/components/jobs/instructor/instructor-open-jobs-list.tsx index 2c032b03..d45bdc4e 100644 --- a/src/components/jobs/instructor/instructor-open-jobs-list.tsx +++ b/src/components/jobs/instructor/instructor-open-jobs-list.tsx @@ -1,6 +1,7 @@ import type { TFunction } from "i18next"; -import { Platform, Text, useWindowDimensions, View } from "react-native"; +import { Text, View } from "react-native"; import Animated, { FadeInUp } from "react-native-reanimated"; +import { DotStatusPill, MetricCell } from "@/components/home/home-shared"; import { KitButton, KitSurface } from "@/components/ui/kit"; import { ProfileAvatar } from "@/components/ui/profile-avatar"; import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; @@ -12,6 +13,7 @@ import { formatTime, getApplicationStatusTranslationKey, } from "@/lib/jobs-utils"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; type OpenJob = { jobId: Id<"jobs">; @@ -36,8 +38,6 @@ type InstructorOpenJobsListProps = { t: TFunction; }; -const WIDE_WEB_BREAKPOINT = 1180; - const STATUS_DOT: Record< NonNullable, { color: keyof BrandPalette; background: keyof BrandPalette } @@ -48,91 +48,6 @@ const STATUS_DOT: Record< withdrawn: { color: "textMuted", background: "surface" }, }; -function StatusPill({ - backgroundColor, - color, - label, -}: { - backgroundColor: string; - color: string; - label: string; -}) { - return ( - - - - {label} - - - ); -} - -function MetricCell({ - align = "flex-start", - label, - value, - valueColor, -}: { - align?: "flex-start" | "flex-end"; - label: string; - value: string; - valueColor: string; -}) { - return ( - - - {label} - - - {value} - - - ); -} - export function InstructorOpenJobsList({ jobs, locale, @@ -142,8 +57,7 @@ export function InstructorOpenJobsList({ onApply, t, }: InstructorOpenJobsListProps) { - const { width } = useWindowDimensions(); - const isWideWeb = Platform.OS === "web" && width >= WIDE_WEB_BREAKPOINT; + const { isDesktopWeb: isWideWeb } = useLayoutBreakpoint(); if (jobs.length === 0) return null; @@ -179,7 +93,7 @@ export function InstructorOpenJobsList({ borderRadius: isWideWeb ? 28 : BrandRadius.card, borderCurve: "continuous", backgroundColor: tone - ? ((palette.surface as string) ?? "#fff") + ? (palette.surface as string) : (palette.surfaceAlt as string), paddingHorizontal: isWideWeb ? 18 : BrandSpacing.lg, paddingVertical: isWideWeb ? 18 : 16, @@ -223,7 +137,7 @@ export function InstructorOpenJobsList({ {toSportLabel(job.sport as never)} {dotColor && pillBackground ? ( - @@ -287,7 +201,7 @@ export function InstructorOpenJobsList({ }} > {job.applicationStatus ? ( - ; }; +type StudioFeedJobSummary = { + pendingApplicationsCount: number; + status: string; +}; + function FeedSectionHeader({ title, subtitle, palette }: FeedSectionHeaderProps) { return ( - {title} + {title} {subtitle ? ( - + {subtitle} ) : null} @@ -53,9 +59,11 @@ export function StudioFeed() { const { width } = useWindowDimensions(); const palette = useBrand(); + const { safeTop } = useAppInsets(); const locale = i18n.resolvedLanguage ?? "en"; const zoneLanguage = locale.toLowerCase().startsWith("he") ? "he" : "en"; const isWideWeb = Platform.OS === "web" && width >= 1180; + const mobileContentPaddingTop = Platform.OS === "android" ? safeTop + BrandSpacing.sm : 0; const signInRoute = "/sign-in" as const; const onboardingRoute = "/onboarding" as const; const instructorJobsRoute = buildRoleTabRoute("instructor", ROLE_TAB_ROUTE_NAMES.jobs); @@ -88,14 +96,19 @@ export function StudioFeed() { [palette.appBg], ); const reviewCount = - studioJobs?.reduce((total, job) => total + job.pendingApplicationsCount, 0) ?? 0; - const openCount = studioJobs?.filter((job) => job.status === "open").length ?? 0; - const filledCount = studioJobs?.filter((job) => job.status === "filled").length ?? 0; + studioJobs?.reduce( + (total: number, job: StudioFeedJobSummary) => total + job.pendingApplicationsCount, + 0, + ) ?? 0; + const openCount = + studioJobs?.filter((job: StudioFeedJobSummary) => job.status === "open").length ?? 0; + const filledCount = + studioJobs?.filter((job: StudioFeedJobSummary) => job.status === "filled").length ?? 0; const reviewQueueJobs = filteredStudioJobsWithPayments.filter( - (job) => job.pendingApplicationsCount > 0, + (job: StudioFeedJobSummary) => job.pendingApplicationsCount > 0, ); const boardJobs = filteredStudioJobsWithPayments.filter( - (job) => job.pendingApplicationsCount === 0, + (job: StudioFeedJobSummary) => job.pendingApplicationsCount === 0, ); const filterOptions = [ { @@ -139,7 +152,6 @@ export function StudioFeed() { return ( Studio operations @@ -244,8 +255,7 @@ export function StudioFeed() { style={{ ...BrandType.micro, color: palette.textMuted as string, - letterSpacing: 1, - textTransform: "uppercase", + letterSpacing: 0.2, }} > {metric.label} @@ -513,9 +523,9 @@ export function StudioFeed() { return ( @@ -552,45 +562,64 @@ export function StudioFeed() { {currentUser.role === "studio" ? ( <> - {[ { label: "Open", - value: String(studioJobs?.filter((job) => job.status === "open").length ?? 0), + value: String( + studioJobs?.filter((job: StudioFeedJobSummary) => job.status === "open") + .length ?? 0, + ), accent: palette.primary as string, }, { label: "Review", value: String( - studioJobs?.reduce((total, job) => total + job.pendingApplicationsCount, 0) ?? + studioJobs?.reduce( + (total: number, job: StudioFeedJobSummary) => + total + job.pendingApplicationsCount, 0, + ) ?? 0, ), }, { label: "Filled", - value: String(studioJobs?.filter((job) => job.status === "filled").length ?? 0), + value: String( + studioJobs?.filter((job: StudioFeedJobSummary) => job.status === "filled") + .length ?? 0, + ), accent: palette.success as string, }, - ].map((item, index) => ( - + ].map((item) => ( + {item.label} @@ -604,18 +633,6 @@ export function StudioFeed() { > {item.value} - {index < 2 ? ( - - ) : null} ))} @@ -624,7 +641,7 @@ export function StudioFeed() { style={{ borderRadius: BrandRadius.card, borderCurve: "continuous", - backgroundColor: palette.primary as string, + backgroundColor: palette.surface as string, padding: 18, gap: 14, }} @@ -633,18 +650,17 @@ export function StudioFeed() { - COMMAND + Operations {jobsStatusFilter === "needs_review" @@ -654,8 +670,7 @@ export function StudioFeed() { Create shifts, review applicants, and move payment work from one lane. @@ -665,12 +680,11 @@ export function StudioFeed() { label={t("jobsTab.form.title", "Post New Job")} icon="plus" onPress={() => createJobSheetRef.current?.expand()} - variant="secondary" + variant="primary" fullWidth={false} - style={{ backgroundColor: palette.onPrimary as string }} /> - + {studioNotificationSettings !== undefined && !studioNotificationSettings?.hasExpoPushToken ? ( diff --git a/src/components/jobs/studio/studio-jobs-list.tsx b/src/components/jobs/studio/studio-jobs-list.tsx index a78ee3a0..93f0d102 100644 --- a/src/components/jobs/studio/studio-jobs-list.tsx +++ b/src/components/jobs/studio/studio-jobs-list.tsx @@ -1,6 +1,7 @@ import type { TFunction } from "i18next"; -import { Platform, Text, useWindowDimensions, View } from "react-native"; +import { Text, View } from "react-native"; import Animated, { FadeInUp } from "react-native-reanimated"; +import { DotStatusPill, MetricCell } from "@/components/home/home-shared"; import { KitButton, KitSurface } from "@/components/ui/kit"; import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { getZoneLabel } from "@/constants/zones"; @@ -19,6 +20,7 @@ import { type PaymentStatus, type PayoutStatus, } from "@/lib/payments-utils"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; type StudioJobApplication = { applicationId: Id<"jobApplications">; @@ -57,8 +59,6 @@ type StudioJobsListProps = { t: TFunction; }; -const WIDE_WEB_BREAKPOINT = 1180; - const PAYMENT_STATUS_KEY: Record = { created: "jobsTab.checkout.paymentStatus.created", pending: "jobsTab.checkout.paymentStatus.pending", @@ -102,90 +102,6 @@ function appStatusDot(status: StudioJobApplication["status"], palette: BrandPale return palette.textMuted as string; } -function StatusPill({ - backgroundColor, - color, - label, -}: { - backgroundColor: string; - color: string; - label: string; -}) { - return ( - - - - {label} - - - ); -} - -function MetricCell({ - align = "flex-start", - label, - value, - valueColor, -}: { - align?: "flex-start" | "flex-end"; - label: string; - value: string; - valueColor: string; -}) { - return ( - - - {label} - - - {value} - - - ); -} - type ApplicationRowProps = { application: StudioJobApplication; isWideWeb: boolean; @@ -220,7 +136,7 @@ function ApplicationRow({ flexDirection: isWideWeb ? "row" : "column", alignItems: isWideWeb ? "center" : "stretch", gap: isWideWeb ? 14 : 10, - borderRadius: 22, + borderRadius: BrandRadius.card, borderCurve: "continuous", backgroundColor: palette.surface as string, paddingHorizontal: 14, @@ -251,14 +167,14 @@ function ApplicationRow({ - = WIDE_WEB_BREAKPOINT; + const { isDesktopWeb: isWideWeb } = useLayoutBreakpoint(); if (jobs.length === 0) return null; @@ -337,10 +252,16 @@ export function StudioJobsList({ ); const pendingLabel = job.pendingApplicationsCount > 0 - ? `${String(job.pendingApplicationsCount)} pending` + ? t("jobsTab.card.pendingCount", { + count: job.pendingApplicationsCount, + defaultValue: `${String(job.pendingApplicationsCount)} pending`, + }) : job.applicationsCount > 0 - ? `${String(job.applicationsCount)} reviewed` - : "No applicants"; + ? t("jobsTab.card.reviewedCount", { + count: job.applicationsCount, + defaultValue: `${String(job.applicationsCount)} reviewed`, + }) + : t("jobsTab.card.noApplicants", { defaultValue: "No applicants" }); const listTone = job.pendingApplicationsCount > 0 ? (palette.primarySubtle as string) @@ -387,16 +308,19 @@ export function StudioJobsList({ > {toSportLabel(job.sport as never)} - {job.pendingApplicationsCount > 0 ? ( - ) : null} @@ -413,33 +337,36 @@ export function StudioJobsList({ style={{ ...BrandType.caption, color: palette.textMuted as string }} numberOfLines={1} > - Assigned instructor: {acceptedApplication.instructorName} + {t("jobsTab.card.assignedInstructor", { + name: acceptedApplication.instructorName, + defaultValue: `Assigned: ${acceptedApplication.instructorName}`, + })} ) : null} @@ -450,7 +377,7 @@ export function StudioJobsList({ flexDirection: isWideWeb ? "row" : "column", alignItems: isWideWeb ? "center" : "stretch", gap: 10, - borderRadius: 22, + borderRadius: BrandRadius.card, borderCurve: "continuous", backgroundColor: palette.surface as string, paddingHorizontal: 14, @@ -468,15 +395,14 @@ export function StudioJobsList({ > - Settlement + {t("jobsTab.card.settlement", { defaultValue: "Settlement" })} - {job.payment?.payoutStatus ? ( - - Review queue + {t("jobsTab.card.reviewQueue", { defaultValue: "Review queue" })} - {String(job.pendingApplicationsCount)} waiting + {t("jobsTab.card.waitingCount", { + count: job.pendingApplicationsCount, + defaultValue: `${String(job.pendingApplicationsCount)} waiting`, + })} @@ -565,7 +493,7 @@ export function StudioJobsList({ ) : acceptedApplication ? ( - Assigned to {acceptedApplication.instructorName}. Historical applicant detail - is compressed once the review queue is clear. + {t("jobsTab.card.assignedTo", { + name: acceptedApplication.instructorName, + defaultValue: `Assigned to ${acceptedApplication.instructorName}`, + })} ) : job.applicationsCount > 0 ? ( - {String(job.applicationsCount)} applicants processed. Reopen the role or post - a new slot if you need another instructor. + {t("jobsTab.card.applicantsProcessed", { + count: job.applicationsCount, + defaultValue: `${String(job.applicationsCount)} applicants processed`, + })} ) : job.status === "open" ? ( - Live on the board. New applicants will land in the review lane first. + {t("jobsTab.card.liveOnBoard", { + defaultValue: "Live on the board — new applicants arrive here.", + })} ) : null} diff --git a/src/components/jobs/studio/use-studio-feed-controller.ts b/src/components/jobs/studio/use-studio-feed-controller.ts index bf82239e..0d7b0e0d 100644 --- a/src/components/jobs/studio/use-studio-feed-controller.ts +++ b/src/components/jobs/studio/use-studio-feed-controller.ts @@ -21,6 +21,24 @@ type UseStudioFeedControllerArgs = { t: TFunction; }; +type StudioControllerJob = { + applications: Array<{ + applicationId: Id<"jobApplications">; + appliedAt: number; + instructorName: string; + message?: string | null; + status: "pending" | "accepted" | "rejected" | "withdrawn"; + }>; + applicationsCount: number; + jobId: Id<"jobs">; + pendingApplicationsCount: number; + pay: number; + sport: string; + startTime: number; + status: "open" | "filled" | "cancelled" | "completed"; + zone: string; +}; + export function useStudioFeedController({ t }: UseStudioFeedControllerArgs) { const currentUser = useQuery(api.users.getCurrentUser); @@ -62,7 +80,7 @@ export function useStudioFeedController({ t }: UseStudioFeedControllerArgs) { const filteredStudioJobs = useMemo(() => { const search = jobsSearchQuery.trim().toLowerCase(); - return (studioJobs ?? []).filter((job) => { + return (studioJobs ?? []).filter((job: StudioControllerJob) => { if (jobsStatusFilter === "needs_review" && job.pendingApplicationsCount === 0) { return false; } @@ -76,7 +94,7 @@ export function useStudioFeedController({ t }: UseStudioFeedControllerArgs) { if (!search) return true; const applicants = job.applications - .map((application) => application.instructorName) + .map((application: { instructorName: string }) => application.instructorName) .join(" "); const haystack = `${job.zone} ${toSportLabel(job.sport as never)} ${applicants}`.toLowerCase(); @@ -122,7 +140,7 @@ export function useStudioFeedController({ t }: UseStudioFeedControllerArgs) { const filteredStudioJobsWithPayments = useMemo( () => - filteredStudioJobs.map((job) => ({ + filteredStudioJobs.map((job: StudioControllerJob) => ({ ...job, payment: latestPaymentByJobId.get(String(job.jobId)) ?? null, })), diff --git a/src/components/layout/desktop-dashboard-frame.tsx b/src/components/layout/desktop-dashboard-frame.tsx index ceb5a76b..ad83965b 100644 --- a/src/components/layout/desktop-dashboard-frame.tsx +++ b/src/components/layout/desktop-dashboard-frame.tsx @@ -1,14 +1,13 @@ import type { PropsWithChildren } from "react"; -import { type StyleProp, useWindowDimensions, View, type ViewStyle } from "react-native"; +import { type StyleProp, View, type ViewStyle } from "react-native"; import { BrandSpacing } from "@/constants/brand"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; -const DESKTOP_FRAME_BREAKPOINT = 1100; const DESKTOP_FRAME_MAX_WIDTH = 1480; export function useDesktopDashboardFrame() { - const { width } = useWindowDimensions(); - const isWideWeb = process.env.EXPO_OS === "web" && width >= DESKTOP_FRAME_BREAKPOINT; + const { isWideFrame: isWideWeb, screenWidth: width } = useLayoutBreakpoint(); const outerPadding = isWideWeb ? Math.max(BrandSpacing.xl, Math.min(48, width * 0.035)) : 0; return { diff --git a/src/components/layout/screen-scaffold.tsx b/src/components/layout/screen-scaffold.tsx new file mode 100644 index 00000000..bddc0ac8 --- /dev/null +++ b/src/components/layout/screen-scaffold.tsx @@ -0,0 +1,91 @@ +import type { PropsWithChildren } from "react"; +import { useEffect } from "react"; +import type { ScrollViewProps, StyleProp, ViewStyle } from "react-native"; +import Animated from "react-native-reanimated"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { DesktopDashboardFrame } from "@/components/layout/desktop-dashboard-frame"; +import { useSystemUi, type InsetTone } from "@/contexts/system-ui-context"; +import { BrandSpacing } from "@/constants/brand"; +import { useAppInsets } from "@/hooks/use-app-insets"; + +type BaseScreenScaffoldProps = { + style?: StyleProp; + topInsetTone?: InsetTone; +}; + +type ScrollScreenScaffoldProps = BaseScreenScaffoldProps & { + mode: "scroll"; + scrollProps?: Omit; + contentContainerStyle?: StyleProp; + useDesktopFrame?: boolean; +}; + +type StaticScreenScaffoldProps = BaseScreenScaffoldProps & { + mode: "static"; +}; + +export type ScreenScaffoldProps = PropsWithChildren< + ScrollScreenScaffoldProps | StaticScreenScaffoldProps +>; + +export function ScreenScaffold(props: ScreenScaffoldProps) { + const insets = useAppInsets(); + const { setTopInsetTone } = useSystemUi(); + const topInsetTone = props.topInsetTone ?? "app"; + + useEffect(() => { + setTopInsetTone(topInsetTone); + return () => { + setTopInsetTone("app"); + }; + }, [setTopInsetTone, topInsetTone]); + + if (props.mode === "static") { + return ( + + {props.children} + + ); + } + + const { + contentContainerStyle, + scrollProps, + style, + children, + useDesktopFrame = true, + } = props; + + const content = useDesktopFrame ? ( + {children} + ) : ( + children + ); + + return ( + + {content} + + ); +} diff --git a/src/components/layout/tab-screen-root.tsx b/src/components/layout/tab-screen-root.tsx deleted file mode 100644 index 269b9b5a..00000000 --- a/src/components/layout/tab-screen-root.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { PropsWithChildren } from "react"; -import type { ScrollViewProps, StyleProp, ViewStyle } from "react-native"; -import Animated from "react-native-reanimated"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { DesktopDashboardFrame } from "@/components/layout/desktop-dashboard-frame"; -import { BrandSpacing } from "@/constants/brand"; -import { useAppInsets } from "@/hooks/use-app-insets"; - -type BaseProps = { - style?: StyleProp; -}; - -type TabScreenRootScrollProps = BaseProps & { - mode: "scroll"; - scrollProps?: Omit; - contentContainerStyle?: StyleProp; -}; - -type TabScreenRootStaticProps = BaseProps & { - mode: "static"; -}; - -export type TabScreenRootProps = PropsWithChildren< - TabScreenRootScrollProps | TabScreenRootStaticProps ->; - -export function TabScreenRoot(props: TabScreenRootProps) { - const insets = useAppInsets(); - - if (props.mode === "static") { - return ( - - {props.children} - - ); - } - - const { contentContainerStyle, scrollProps, style, children } = props; - - return ( - - {children} - - ); -} diff --git a/src/components/layout/tab-screen-scroll-view.tsx b/src/components/layout/tab-screen-scroll-view.tsx index 7cf275c1..fc460f88 100644 --- a/src/components/layout/tab-screen-scroll-view.tsx +++ b/src/components/layout/tab-screen-scroll-view.tsx @@ -3,7 +3,8 @@ import { type ScrollViewProps, type StyleProp, View, type ViewStyle } from "reac import type Animated from "react-native-reanimated"; import type { AnimatedRef } from "react-native-reanimated"; import { DesktopDashboardFrame } from "@/components/layout/desktop-dashboard-frame"; -import { TabScreenRoot } from "@/components/layout/tab-screen-root"; +import { ScreenScaffold } from "@/components/layout/screen-scaffold"; +import type { InsetTone } from "@/contexts/system-ui-context"; type TabScreenContainerProps = PropsWithChildren<{ style?: StyleProp; @@ -12,17 +13,18 @@ type TabScreenContainerProps = PropsWithChildren<{ type TabScreenScrollViewProps = PropsWithChildren< Omit & { contentContainerStyle?: StyleProp; - routeKey: string; animatedRef?: AnimatedRef; + topInsetTone?: InsetTone; + useDesktopFrame?: boolean; } >; export function TabScreenContainer({ children, style }: TabScreenContainerProps) { return ( - + {children} - + ); } @@ -30,20 +32,21 @@ export function TabScreenContainer({ children, style }: TabScreenContainerProps) export function TabScreenScrollView({ children, contentContainerStyle, - routeKey, onScroll, animatedRef, style, scrollIndicatorInsets, + topInsetTone, + useDesktopFrame, ...props }: TabScreenScrollViewProps) { - void routeKey; - return ( - {children} - + ); } diff --git a/src/components/layout/top-sheet-surface.tsx b/src/components/layout/top-sheet-surface.tsx new file mode 100644 index 00000000..bacf8869 --- /dev/null +++ b/src/components/layout/top-sheet-surface.tsx @@ -0,0 +1,51 @@ +import { useEffect, type PropsWithChildren } from "react"; +import type { ColorValue, StyleProp, ViewProps, ViewStyle } from "react-native"; +import Animated from "react-native-reanimated"; + +import { BrandRadius } from "@/constants/brand"; +import { useSystemUi } from "@/contexts/system-ui-context"; +import { useKitTheme } from "@/components/ui/kit"; + +type TopSheetSurfaceProps = PropsWithChildren<{ + backgroundColor?: ColorValue; + pointerEvents?: ViewProps["pointerEvents"]; + style?: StyleProp; +}>; + +export function TopSheetSurface({ + children, + backgroundColor, + pointerEvents, + style, +}: TopSheetSurfaceProps) { + const { background } = useKitTheme(); + const { setTopInsetTone, setTopInsetBackgroundColor } = useSystemUi(); + const resolvedBackground = backgroundColor ?? background.sheet; + + useEffect(() => { + setTopInsetTone("sheet"); + setTopInsetBackgroundColor(resolvedBackground); + return () => { + setTopInsetTone("app"); + setTopInsetBackgroundColor(null); + }; + }, [resolvedBackground, setTopInsetBackgroundColor, setTopInsetTone]); + + return ( + + {children} + + ); +} diff --git a/src/components/map-tab/map-tab-screen.tsx b/src/components/map-tab/map-tab-screen.tsx index 08afe1bd..476497eb 100644 --- a/src/components/map-tab/map-tab-screen.tsx +++ b/src/components/map-tab/map-tab-screen.tsx @@ -6,6 +6,7 @@ import BottomSheet, { import { useIsFocused } from "@react-navigation/native"; import { useMutation, useQuery } from "convex/react"; import * as Haptics from "expo-haptics"; +import * as Location from "expo-location"; import { Redirect } from "expo-router"; import { type RefObject, @@ -19,10 +20,12 @@ import { import { useTranslation } from "react-i18next"; import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import Animated from "react-native-reanimated"; +import { NoticeBanner } from "@/components/jobs/notice-banner"; +import { ScreenScaffold } from "@/components/layout/screen-scaffold"; import { TabOverlayAnchor } from "@/components/layout/tab-overlay-anchor"; -import { TabScreenRoot } from "@/components/layout/tab-screen-root"; import { LoadingScreen } from "@/components/loading-screen"; import { QueueMap } from "@/components/maps/queue-map"; +import type { QueueMapPin } from "@/components/maps/queue-map.types"; import { ThemedText } from "@/components/themed-text"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitButton, KitPressable } from "@/components/ui/kit"; @@ -32,6 +35,7 @@ import { ZONE_OPTIONS, type ZoneOption } from "@/constants/zones"; import { api } from "@/convex/_generated/api"; import { useAppInsets } from "@/hooks/use-app-insets"; import { useBrand } from "@/hooks/use-brand"; +import { resolveCurrentLocationToZone } from "@/lib/location-zone"; const MAX_ZONES = 25; @@ -55,14 +59,45 @@ export default function MapTabScreen() { const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [focusZoneId, setFocusZoneId] = useState(null); + const [mapPin, setMapPin] = useState(null); const zoneSheetRef = useRef(null); const noopMapPress = useCallback(() => {}, []); - const handleRecenter = useCallback(() => { + const handleFocusSelection = useCallback(() => { const nextFocusZoneId = focusZoneId ?? selectedZoneIds[0] ?? remoteZones?.zoneIds?.[0] ?? null; if (!nextFocusZoneId) return; setFocusZoneId(nextFocusZoneId); }, [focusZoneId, remoteZones?.zoneIds, selectedZoneIds]); + const handleUseGps = useCallback(async () => { + setSaveError(null); + try { + if (Platform.OS === "ios") { + void Haptics.selectionAsync(); + } + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== "granted") { + throw new Error( + t("mapTab.errors.locationPermission", { + defaultValue: "Permission to access location was denied.", + }), + ); + } + const location = await Location.getCurrentPositionAsync({}); + setMapPin({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }); + const resolved = await resolveCurrentLocationToZone(); + setFocusZoneId(resolved.zoneId); + } catch (error) { + setSaveError( + error instanceof Error + ? error.message + : t("mapTab.errors.failedToSave", { defaultValue: "Failed to load location" }), + ); + } + }, [t]); + useEffect(() => { if (!remoteZones) return; setSelectedZoneIds(remoteZones.zoneIds ?? []); @@ -98,7 +133,7 @@ export default function MapTabScreen() { [focusZoneId, t], ); - const persistedZoneIds = remoteZones?.zoneIds ?? []; + const persistedZoneIds = (remoteZones?.zoneIds ?? []) as string[]; const hasChanges = useMemo(() => { if (persistedZoneIds.length !== selectedZoneIds.length) return true; @@ -167,7 +202,13 @@ export default function MapTabScreen() { const shouldSave = hasChanges; if (shouldSave && isSaving) return; - if (!shouldSave) return; + if (!hasChanges) { + setZoneModeActive(false); + setSheetIndex(-1); + setZoneSearch(""); + zoneSheetRef.current?.close(); + return; + } setIsSaving(true); setSaveError(null); @@ -224,7 +265,11 @@ export default function MapTabScreen() { if (Platform.OS === "web") { return ( - + @@ -745,87 +790,96 @@ export default function MapTabScreen() { - + ); } return ( - + {isFocused ? ( <> + {saveError ? ( + + setSaveError(null)} + borderColor={palette.borderStrong} + backgroundColor={palette.surface} + textColor={palette.danger} + iconColor={palette.danger} + /> + + ) : null} - + {zoneModeActive ? ( - - {zoneModeActive ? "Editing coverage" : "Coverage live"} - - - {String(selectedZoneIds.length)} active zones - - - {zoneModeActive - ? hasChanges + + Editing coverage + + + {String(selectedZoneIds.length)} active zones + + + {hasChanges ? `${String(pendingChangeCount)} staged edits ready to save.` - : "Tap the map or list to stage coverage changes." - : "Open edit mode to add or trim your territory."} - + : "Tap the map or list to stage coverage changes."} + + - + ) : null} {saveError ? ( @@ -1027,7 +1081,7 @@ export default function MapTabScreen() { ) : null} - + ); } @@ -1071,4 +1125,20 @@ const styles = StyleSheet.create({ marginHorizontal: 16, marginBottom: 8, }, + saveBannerWrap: { + position: "absolute", + top: 16, + left: 16, + right: 16, + zIndex: 50, + }, + modeHint: { + marginTop: 8, + borderWidth: 1, + borderRadius: 999, + borderCurve: "continuous", + paddingHorizontal: 10, + paddingVertical: 6, + alignSelf: "flex-end", + }, }); diff --git a/src/components/maps/queue-map-zone-polygons.tsx b/src/components/maps/queue-map-zone-polygons.tsx index aef703a6..e44f1f39 100644 --- a/src/components/maps/queue-map-zone-polygons.tsx +++ b/src/components/maps/queue-map-zone-polygons.tsx @@ -42,7 +42,7 @@ export function QueueMapZonePolygons({ const zoneOutlineWidth = showAllZones ? Math.max(APPLE_MAP_THEME.overlay.baseOutlineWidth, 1.35) : APPLE_MAP_THEME.overlay.baseOutlineWidth; - const selectedLabelOpacity = showAllZones ? 0 : 1; + const selectedLabelOpacity = 1; const allZoneLabelOpacity = showAllZones ? 0.92 : 0; return ( @@ -110,12 +110,13 @@ export function QueueMapZonePolygons({ id="queue-zone-selected-labels" type="symbol" filter={selectedZoneFilter as any} - minzoom={9.5 as any} + minzoom={6 as any} layout={{ "symbol-placement": "point", "text-field": ["coalesce", ["get", "engName"], ["get", "hebName"], ["get", "id"]] as any, - "text-size": ["interpolate", ["linear"], ["zoom"], 9.5, 10, 11, 12, 14, 14] as any, - "text-allow-overlap": false, + "text-size": ["interpolate", ["linear"], ["zoom"], 6, 10, 9.5, 11, 12, 13, 14, 14] as any, + "text-allow-overlap": true, + "text-ignore-placement": true, "text-font": ["Noto Sans Regular"] as any, }} paint={{ diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 50f81348..db0bf7aa 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -6,18 +6,19 @@ import { OfflineManager, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { StyleSheet, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, InteractionManager, StyleSheet, View } from "react-native"; import { APPLE_MAP_THEME } from "@/components/maps/queue-map-apple-theme"; import { QueueMapZonePolygons } from "@/components/maps/queue-map-zone-polygons"; import { ThemedText } from "@/components/themed-text"; -import { getMapBrandPalette } from "@/constants/brand"; +import { BrandSpacing, getMapBrandPalette } from "@/constants/brand"; import { getZoneIndexEntry, ISRAEL_MAP_INTERACTION_BOUNDS } from "@/constants/zones-map"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; import { IconSymbol } from "../ui/icon-symbol"; -import { KitFab, KitSurface } from "../ui/kit"; +import { KitButton, KitFab, KitSurface } from "../ui/kit"; import type { QueueMapPin, QueueMapProps } from "./queue-map.types"; type Expression = unknown; @@ -28,6 +29,7 @@ type AnyStyleSpec = { layers: AnyStyleLayer[]; [key: string]: unknown; }; +type MapLoadState = "loading" | "ready" | "error"; const NO_MATCH_ZONE_FILTER: Expression = ["==", ["get", "id"], "__none__"]; let offlinePackBootstrapPromise: Promise | null = null; @@ -202,13 +204,20 @@ export function QueueMap({ onUseGps, showGpsButton = true, }: QueueMapProps) { + const { t } = useTranslation(); const palette = useBrand(); - const { resolvedScheme, stylePreference } = useThemePreference(); - const mapPalette = getMapBrandPalette(stylePreference, resolvedScheme); - const [styleLoadFailed, setStyleLoadFailed] = useState(false); + const { resolvedScheme } = useThemePreference(); + const mapPalette = getMapBrandPalette(resolvedScheme); + const [mapLoadState, setMapLoadState] = useState("loading"); + const [retryNonce, setRetryNonce] = useState(0); + const [mapErrorMessage, setMapErrorMessage] = useState(null); const [baseMapStyle, setBaseMapStyle] = useState(null); const preferredStyleUrl = resolvedScheme === "dark" ? APPLE_MAP_THEME.mapStyleDarkUrl : APPLE_MAP_THEME.mapStyleLightUrl; + const styleFetchUrl = + retryNonce === 0 + ? preferredStyleUrl + : `${preferredStyleUrl}${preferredStyleUrl.includes("?") ? "&" : "?"}queueRetry=${String(retryNonce)}`; const themedMapStyle = useMemo(() => { if (!baseMapStyle) return null; return withMapPersonality( @@ -218,46 +227,66 @@ export function QueueMap({ resolvedScheme === "dark", ); }, [baseMapStyle, mapPalette, mode, resolvedScheme]); - const mapStyle = styleLoadFailed - ? { - version: 8, - sources: {}, - layers: [ - { - id: "background", - type: "background", - paint: { "background-color": mapPalette.surfaceAlt }, - }, - ], - } - : (themedMapStyle ?? preferredStyleUrl); + const mapStyle = themedMapStyle ?? preferredStyleUrl; + const mapKey = `${resolvedScheme}:${retryNonce}:${themedMapStyle ? "themed" : "url"}`; - const cameraRef = useRef<{ setStop: (config: unknown) => void } | null>(null); + const cameraRef = useRef<{ + setStop: (config: unknown) => void; + flyTo: (options: { center: [number, number]; zoom?: number; duration?: number }) => void; + } | null>(null); const selectedZoneFilter = useMemo( () => createZoneFilter(selectedZoneIds, zoneIdProperty), [selectedZoneIds, zoneIdProperty], ); const pinShape = useMemo(() => createPinShape(pin), [pin]); + const handleRetry = useCallback(() => { + setBaseMapStyle(null); + setMapErrorMessage(null); + setMapLoadState("loading"); + setRetryNonce((current) => current + 1); + }, []); useEffect(() => { - void ensureVectorOfflinePack(); - }, []); + if (mapLoadState !== "ready") return; + + let cancelled = false; + const task = InteractionManager.runAfterInteractions(() => { + if (cancelled) return; + void ensureVectorOfflinePack().catch(() => {}); + }); + + return () => { + cancelled = true; + task.cancel(); + }; + }, [mapLoadState]); useEffect(() => { let cancelled = false; const controller = new AbortController(); (async () => { + if (!cancelled) { + setBaseMapStyle(null); + } try { - const response = await fetch(preferredStyleUrl, { signal: controller.signal }); + const response = await fetch(styleFetchUrl, { + signal: controller.signal, + }); if (!response.ok) { - if (!cancelled) setBaseMapStyle(null); + if (!cancelled) { + setBaseMapStyle(null); + } return; } const baseStyle = (await response.json()) as AnyStyleSpec; - if (!cancelled) setBaseMapStyle(baseStyle); + if (!cancelled) { + setBaseMapStyle(baseStyle); + } } catch { - if (!cancelled) setBaseMapStyle(null); + if (!cancelled) { + setBaseMapStyle(null); + } } })(); @@ -265,7 +294,7 @@ export function QueueMap({ cancelled = true; controller.abort(); }; - }, [preferredStyleUrl]); + }, [styleFetchUrl]); useEffect(() => { if (!focusZoneId) return; @@ -285,6 +314,15 @@ export function QueueMap({ }); }, [focusZoneId]); + useEffect(() => { + if (!pin) return; + cameraRef.current?.flyTo({ + center: [pin.longitude, pin.latitude], + zoom: APPLE_MAP_THEME.defaultZoomWithPin, + duration: 800, + }); + }, [pin]); + if (Constants.appOwnership === "expo") { return ( { + setMapLoadState("loading"); + setMapErrorMessage(null); + }} + onDidFinishLoadingMap={() => { + setMapLoadState("ready"); + setMapErrorMessage(null); + }} onDidFailLoadingMap={() => { - setStyleLoadFailed(true); + setMapLoadState("error"); + setMapErrorMessage( + "The map could not finish loading. Check your connection and try again.", + ); }} onPress={(event: any) => { if (mode !== "pinDrop") return; @@ -365,6 +415,72 @@ export function QueueMap({ + {mapLoadState === "loading" ? ( + + + + + {t("mapTab.loading", { defaultValue: "Loading your map..." })} + + + {t("mapTab.subtitle", { + defaultValue: "Adjust your active zones directly on the map.", + })} + + + + ) : null} + + {mapLoadState === "error" ? ( + + + Map unavailable + + {mapErrorMessage ?? + "The map could not finish loading. Check your connection and try again."} + + + + + ) : null} + {showGpsButton && onUseGps ? ( } @@ -388,8 +504,8 @@ const styles = StyleSheet.create({ map: { flex: 1 }, gps: { position: "absolute", - right: 16, - bottom: 16, + right: BrandSpacing.lg, + bottom: BrandSpacing.lg, }, fallback: { alignItems: "center", @@ -398,4 +514,19 @@ const styles = StyleSheet.create({ paddingHorizontal: 18, paddingVertical: 16, }, + stateOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + padding: 18, + }, + stateCard: { + width: "100%", + maxWidth: 360, + alignItems: "center", + gap: 10, + paddingHorizontal: 18, + paddingVertical: 16, + borderWidth: StyleSheet.hairlineWidth, + }, }); diff --git a/src/components/profile/profile-editor-form.tsx b/src/components/profile/profile-editor-form.tsx index 9a54e84d..2818c879 100644 --- a/src/components/profile/profile-editor-form.tsx +++ b/src/components/profile/profile-editor-form.tsx @@ -1,5 +1,7 @@ import { useMemo, useState } from "react"; -import { ScrollView, Text, type TextInputProps, useWindowDimensions, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { ScrollView, Text, type TextInputProps, View } from "react-native"; +import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { PROFILE_SOCIAL_FIELDS, @@ -10,7 +12,7 @@ import { SportsMultiSelect } from "@/components/profile/sports-multi-select"; import { KitButton, KitSurface, KitTextField } from "@/components/ui/kit"; import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; -import { BrandSpacing } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; type EditableExtraField = { label: string; @@ -69,25 +71,25 @@ export function ProfileEditorForm({ sportsEmptyHint, extraField, }: ProfileEditorFormProps) { - const { width } = useWindowDimensions(); - const isDesktopWeb = process.env.EXPO_OS === "web" && width >= 1180; + const { isDesktopWeb } = useLayoutBreakpoint(); const activeSocialCount = useMemo( () => PROFILE_SOCIAL_FIELDS.filter((field) => Boolean(socialLinksDraft[field.key]?.trim())).length, [socialLinksDraft], ); const [showSocialFields, setShowSocialFields] = useState(activeSocialCount > 0); + const { t } = useTranslation(); const saveActions = ( - + ); @@ -116,7 +118,7 @@ export function ProfileEditorForm({ - Social links + {t("profile.editor.socialLinks", { defaultValue: "Social links" })} {activeSocialCount > 0 - ? `${String(activeSocialCount)} linked` - : "Add only the links you actually use."} + ? t("profile.editor.linked", { count: activeSocialCount, defaultValue: `${String(activeSocialCount)} linked` }) + : t("profile.editor.addLinks", { defaultValue: "Add only the links you actually use." })} setShowSocialFields((value) => !value)} variant="ghost" size="sm" @@ -322,8 +323,7 @@ export function ProfileEditorForm({ {statusLabel ? ( void; -}; - -type ProfileHeroHighlight = { - label: string; - value: string; - caption?: string; - accent?: string; + icon?: + | "sparkles" + | "slider.horizontal.3" + | "checkmark.circle.fill" + | "calendar.badge.clock" + | "mappin.and.ellipse"; }; type ProfileHeroSheetProps = { @@ -73,65 +74,16 @@ function getProfileSummary( return ( bio?.trim() || (activeSocialCount > 0 - ? `${String(activeSocialCount)} links ready for your public profile` + ? t("profile.hero.linksReady", { + count: activeSocialCount, + defaultValue: `${String(activeSocialCount)} links ready for your public profile`, + }) : t("profile.hero.focused", { defaultValue: "Keep your public profile focused and easy to scan.", })) ); } -function HeroActionButton({ - label, - onPress, - palette, - kind = "primary", -}: { - label: string; - onPress: () => void; - palette: BrandPalette; - kind?: "primary" | "secondary" | "light"; -}) { - const backgroundColor = - kind === "light" - ? (palette.surface as string) - : kind === "secondary" - ? "rgba(255,255,255,0.16)" - : (palette.text as string); - const textColor = - kind === "secondary" ? (palette.surface as string) : kind === "light" ? "#0A0A0A" : "#FFFFFF"; - - return ( - [ - { - minHeight: 48, - borderRadius: BrandRadius.button, - borderCurve: "continuous", - backgroundColor, - paddingHorizontal: 18, - alignItems: "center", - justifyContent: "center", - opacity: pressed ? 0.88 : 1, - }, - ]} - > - - {label} - - - ); -} - export function ProfileHeroSheet({ profileName, roleLabel, @@ -204,8 +156,8 @@ export function ProfileHeroSheet({ const expandedDetailsStyle = useAnimatedStyle(() => ({ opacity: interpolate(scrollY.value, [0, 80], [1, 0], Extrapolation.CLAMP), - height: interpolate(scrollY.value, [0, 80], [112, 0], Extrapolation.CLAMP), - marginTop: interpolate(scrollY.value, [0, 80], [16, 0], Extrapolation.CLAMP), + height: interpolate(scrollY.value, [0, 80], [88, 0], Extrapolation.CLAMP), + marginTop: interpolate(scrollY.value, [0, 80], [14, 0], Extrapolation.CLAMP), })); const animatedSheetStyle = useAnimatedStyle(() => { @@ -223,8 +175,9 @@ export function ProfileHeroSheet({ }); return ( - - - - - + + + + + + + + + + + {roleLabel} + + + {profileName} + + + {sportsLabel} + - - - {roleLabel} - - - {profileName} - - - {sportsLabel} - - - {summaryLabel} - - + + + {secondaryAction ? ( + + ) : null} + @@ -328,8 +296,7 @@ export function ProfileHeroSheet({ style={{ ...BrandType.micro, color: palette.primary as string, - letterSpacing: 0.8, - textTransform: "uppercase", + letterSpacing: 0.2, }} > {statusLabel} @@ -349,34 +316,26 @@ export function ProfileHeroSheet({ style={{ ...BrandType.micro, color: palette.textMuted as string, - letterSpacing: 0.8, - textTransform: "uppercase", + letterSpacing: 0.2, }} > {sportsLabel} - - - - - - {secondaryAction ? ( - - - - ) : null} - + + {summaryLabel} + - + ); } @@ -390,7 +349,6 @@ export function ProfileDesktopHeroPanel({ metaLabel, primaryAction, secondaryAction, - highlights, }: { profileName: string; roleLabel: string; @@ -401,14 +359,15 @@ export function ProfileDesktopHeroPanel({ metaLabel?: string | undefined; primaryAction: ProfileHeroAction; secondaryAction?: ProfileHeroAction | undefined; - highlights: ProfileHeroHighlight[]; }) { return ( {roleLabel} @@ -436,11 +394,11 @@ export function ProfileDesktopHeroPanel({ {profileName} @@ -454,7 +412,9 @@ export function ProfileDesktopHeroPanel({ alignSelf: "flex-start", borderRadius: 999, borderCurve: "continuous", - backgroundColor: "rgba(255,255,255,0.16)", + backgroundColor: palette.surface as string, + borderWidth: 1, + borderColor: palette.border as string, paddingHorizontal: 12, paddingVertical: 8, }} @@ -462,9 +422,8 @@ export function ProfileDesktopHeroPanel({ {statusLabel} @@ -473,8 +432,7 @@ export function ProfileDesktopHeroPanel({ {summary} @@ -483,8 +441,7 @@ export function ProfileDesktopHeroPanel({ {metaLabel} @@ -494,74 +451,26 @@ export function ProfileDesktopHeroPanel({ - {secondaryAction ? ( - ) : null} - - - {highlights.map((highlight) => ( - - - {highlight.label} - - - {highlight.value} - - {highlight.caption ? ( - - {highlight.caption} - - ) : null} - - ))} - ); } diff --git a/src/components/profile/profile-readiness-banner.tsx b/src/components/profile/profile-readiness-banner.tsx new file mode 100644 index 00000000..8040d2c5 --- /dev/null +++ b/src/components/profile/profile-readiness-banner.tsx @@ -0,0 +1,75 @@ +import { Text, View } from "react-native"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { KitButton, KitSurface } from "@/components/ui/kit"; +import type { BrandPalette } from "@/constants/brand"; +import { BrandRadius, BrandType } from "@/constants/brand"; + +export type ProfileSetupAction = { + label: string; + onPress: () => void; +}; + +export type ProfileReadinessBannerProps = { + palette: BrandPalette; + primaryAction: ProfileSetupAction | null; + statusLabel: string; + subtitleLabel: string; +}; + +export function ProfileReadinessBanner({ + palette, + primaryAction, + statusLabel, + subtitleLabel, +}: ProfileReadinessBannerProps) { + return ( + + + + + + + + + {statusLabel} + + + {subtitleLabel} + + + + {primaryAction ? ( + + ) : null} + + + ); +} diff --git a/src/components/profile/profile-readiness-strip.tsx b/src/components/profile/profile-readiness-strip.tsx deleted file mode 100644 index 87326a64..00000000 --- a/src/components/profile/profile-readiness-strip.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Text, useWindowDimensions, View } from "react-native"; - -import { IconSymbol } from "@/components/ui/icon-symbol"; -import { type BrandPalette, BrandRadius, BrandType } from "@/constants/brand"; -import { KitPressable } from "../ui/kit"; - -type ReadinessItem = { - label: string; - value: string; - caption?: string; - accent?: string; - onPress?: (() => void) | undefined; -}; - -export function ProfileReadinessStrip({ - items, - palette, - columns, -}: { - items: ReadinessItem[]; - palette: BrandPalette; - columns?: 1 | 2 | 4; -}) { - const { width } = useWindowDimensions(); - const resolvedColumns = columns ?? (process.env.EXPO_OS === "web" && width >= 1180 ? 4 : 2); - const horizontalPadding = 48; - const totalGap = resolvedColumns === 1 ? 0 : 10 * (resolvedColumns - 1); - const tileWidth = Math.max( - resolvedColumns === 4 ? 150 : 180, - Math.floor((width - horizontalPadding - totalGap) / resolvedColumns), - ); - - return ( - - {items.map((item) => { - const content = ( - - - - {item.label} - - {item.onPress ? ( - - - - ) : null} - - - {item.value} - - {item.caption ? ( - - {item.caption} - - ) : null} - - ); - - if (!item.onPress) { - return ( - - {content} - - ); - } - - return ( - [{ width: tileWidth, opacity: pressed ? 0.84 : 1 }]} - > - {content} - - ); - })} - - ); -} diff --git a/src/components/profile/profile-settings-sections.tsx b/src/components/profile/profile-settings-sections.tsx index b6726352..50a14e22 100644 --- a/src/components/profile/profile-settings-sections.tsx +++ b/src/components/profile/profile-settings-sections.tsx @@ -1,9 +1,11 @@ -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { Text, View } from "react-native"; import { IconSymbol } from "@/components/ui/icon-symbol"; -import { KitPressable } from "@/components/ui/kit"; -import { type BrandPalette, BrandSpacing, BrandType } from "@/constants/brand"; +import { KitPressable, KitSurface } from "@/components/ui/kit"; +import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; + +type ProfileSymbolName = ComponentProps["name"]; export function ProfileSectionHeader({ label, @@ -29,8 +31,7 @@ export function ProfileSectionHeader({ style={{ ...BrandType.micro, color: palette.textMuted as string, - letterSpacing: 1.0, - textTransform: "uppercase", + letterSpacing: 0.4, }} > {label} @@ -40,7 +41,7 @@ export function ProfileSectionHeader({ style={{ ...BrandType.caption, color: palette.textMuted as string, - maxWidth: 520, + maxWidth: 540, }} > {description} @@ -50,107 +51,168 @@ export function ProfileSectionHeader({ ); } +export function ProfileSectionCard({ + children, + palette, + style, +}: { + children: ReactNode; + palette: BrandPalette; + style?: ComponentProps["style"]; +}) { + return ( + + {children} + + ); +} + +export function ProfileIconButton({ + icon, + label, + onPress, + palette, + tone = "neutral", +}: { + icon: ProfileSymbolName; + label: string; + onPress: () => void; + palette: BrandPalette; + tone?: "neutral" | "accent"; +}) { + const backgroundColor = + tone === "accent" ? (palette.primarySubtle as string) : (palette.surface as string); + const iconColor = tone === "accent" ? (palette.primary as string) : (palette.text as string); + + return ( + [ + { + width: 40, + height: 40, + borderRadius: 20, + borderCurve: "continuous", + alignItems: "center", + justifyContent: "center", + backgroundColor, + borderWidth: 1, + borderColor: palette.border as string, + opacity: pressed ? 0.82 : 1, + }, + ]} + > + + + ); +} + export function ProfileSettingRow({ - eyebrow, title, subtitle, + value, + icon, accessory, onPress, palette, - isLast = false, tone = "default", + showDivider = false, }: { - eyebrow?: string; title: string; subtitle?: string; + value?: string; + icon?: ProfileSymbolName; accessory?: ReactNode; onPress?: () => void; palette: BrandPalette; - isLast?: boolean; tone?: "default" | "danger"; + showDivider?: boolean; }) { - const rowBackgroundColor = - tone === "danger" - ? (palette.dangerSubtle as string) - : onPress - ? (palette.surface as string) - : (palette.surfaceAlt as string); const titleColor = tone === "danger" ? (palette.danger as string) : (palette.text as string); - const subtitleColor = + const secondaryColor = tone === "danger" ? (palette.danger as string) : (palette.textMuted as string); + const iconBackground = + tone === "danger" ? (palette.dangerSubtle as string) : (palette.surfaceAlt as string); + const iconColor = tone === "danger" ? (palette.danger as string) : (palette.primary as string); + const borderColor = tone === "danger" ? "transparent" : (palette.border as string); const content = ( 36 ? "flex-start" : "center", + gap: 14, + paddingHorizontal: 16, + paddingVertical: 15, + borderBottomWidth: showDivider ? 1 : 0, + borderBottomColor: borderColor, }} > - - {eyebrow ? ( - - {eyebrow} - - ) : null} + {icon ? ( + + + + ) : null} + + {title} {subtitle ? ( - - {subtitle} - + {subtitle} ) : null} + + {value ? ( + + {value} + + ) : null} {accessory ?? - (onPress ? ( - - - - ) : null)} + (onPress ? : null)} ); @@ -162,9 +224,9 @@ export function ProfileSettingRow({ return ( [{ opacity: pressed ? 0.82 : 1 }]} + style={({ pressed }) => [{ opacity: pressed ? 0.8 : 1 }]} > {content} diff --git a/src/components/themed-text.tsx b/src/components/themed-text.tsx index 4f9bd52a..e2215448 100644 --- a/src/components/themed-text.tsx +++ b/src/components/themed-text.tsx @@ -2,7 +2,7 @@ import { I18nManager, Text, type TextProps } from "react-native"; import { BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; -import { useThemeColor } from "@/hooks/use-theme-color"; +import { useThemePreference } from "@/hooks/use-theme-preference"; export type ThemedTextType = | "display" @@ -10,8 +10,18 @@ export type ThemedTextType = | "title" | "body" | "bodyStrong" + | "bodyMedium" | "caption" | "micro" + | "screenTitle" + | "sheetTitle" + | "sectionLabel" + | "sectionTitle" + | "cardTitle" + | "meta" + | "pillLabel" + | "buttonLabel" + | "statValue" // Legacy aliases — kept for backwards compatibility | "default" | "defaultSemiBold" @@ -30,6 +40,18 @@ const LEGACY_TYPE_MAP: Partial> = { subtitle: "title", }; +const SEMANTIC_TYPE_MAP: Partial> = { + screenTitle: "heading", + sheetTitle: "title", + sectionLabel: "micro", + sectionTitle: "title", + cardTitle: "bodyStrong", + meta: "caption", + pillLabel: "micro", + buttonLabel: "bodyMedium", + statValue: "heading", +}; + export function ThemedText({ style, lightColor, @@ -39,18 +61,20 @@ export function ThemedText({ }: ThemedTextProps) { const resolved = (LEGACY_TYPE_MAP[type as string] ?? type) as ThemedTextType; const palette = useBrand(); + const { resolvedScheme } = useThemePreference(); - const color = useThemeColor({ - light: lightColor ?? (palette.text as string), - dark: darkColor ?? (palette.text as string), - }); + const color = + resolvedScheme === "dark" ? (darkColor ?? palette.text) : (lightColor ?? palette.text); const linkColor = palette.primary; + const mappedType = SEMANTIC_TYPE_MAP[resolved]; const typeStyle = resolved === "link" ? { ...BrandType.body, fontWeight: "600" as const, color: linkColor } - : (BrandType[resolved as keyof typeof BrandType] ?? BrandType.body); + : mappedType + ? BrandType[mappedType] + : (BrandType[resolved as keyof typeof BrandType] ?? BrandType.body); return ( - ); -} diff --git a/src/components/ui/icon-symbol.tsx b/src/components/ui/icon-symbol.tsx index a361306c..c94589ad 100644 --- a/src/components/ui/icon-symbol.tsx +++ b/src/components/ui/icon-symbol.tsx @@ -28,6 +28,7 @@ const MAPPING = { calendar: "calendar-today", "calendar.badge.clock": "event", "calendar.circle.fill": "event", + "creditcard.fill": "credit-card", checkmark: "check", "house.fill": "home", "map.fill": "map", @@ -35,14 +36,18 @@ const MAPPING = { "clock.fill": "schedule", "exclamationmark.circle.fill": "error", "flame.fill": "local-fire-department", + globe: "language", "gym.bag.fill": "fitness-center", "mappin.and.ellipse": "place", "mappin.circle.fill": "location-on", magnifyingglass: "search", + "moon.fill": "dark-mode", + pencil: "edit", "person.crop.circle.fill": "account-circle", "person.3.sequence.fill": "groups", "quote.bubble.fill": "format-quote", "slider.horizontal.3": "tune", + sparkles: "auto-awesome", "checkmark.circle.fill": "check-circle", "location.fill": "my-location", "paperplane.fill": "send", diff --git a/src/components/ui/kit/kit-button.tsx b/src/components/ui/kit/kit-button.tsx index df57c474..521b2a1c 100644 --- a/src/components/ui/kit/kit-button.tsx +++ b/src/components/ui/kit/kit-button.tsx @@ -24,7 +24,7 @@ function getButtonColors( variant: NonNullable, theme: ReturnType, ): ButtonColors { - const { color, background, foreground, border, shadow, isCustomStyle } = theme; + const { color, background, foreground, border, shadow } = theme; if (variant === "primary") { return { backgroundColor: color.primary, @@ -52,7 +52,7 @@ function getButtonColors( textColor: foreground.secondary, iconColor: color.primary, highlight: border.primary, - shadow: isCustomStyle ? shadow.surface : undefined, + shadow: shadow.surface, }; } return { @@ -69,7 +69,7 @@ function getButtonSize(size: NonNullable) { return { minHeight: 42, horizontal: 14, fontSize: 13 }; } if (size === "lg") { - return { minHeight: 56, horizontal: 20, fontSize: 16 }; + return { minHeight: 54, horizontal: 20, fontSize: 16 }; } return { minHeight: 48, horizontal: 18, fontSize: 14 }; } @@ -88,7 +88,6 @@ export function KitButton({ style, }: KitButtonProps) { const theme = useKitTheme(); - const { isCustomStyle } = theme; const colors = getButtonColors(variant, theme); const sizing = getButtonSize(size); const isDisabled = disabled || loading; @@ -101,7 +100,7 @@ export function KitButton({ disabled={isDisabled} onPress={(event) => onPress(event)} haptic="impact" - nativeFeedback={!isCustomStyle} + nativeFeedback pressStyle={isDisabled ? undefined : { transform: [{ scale: 0.985 }] }} style={[ { @@ -134,11 +133,10 @@ export function KitButton({ ) : null} diff --git a/src/components/ui/kit/kit-chip.tsx b/src/components/ui/kit/kit-chip.tsx index 39c1f1c3..0a93620d 100644 --- a/src/components/ui/kit/kit-chip.tsx +++ b/src/components/ui/kit/kit-chip.tsx @@ -12,14 +12,14 @@ export function KitChip({ onPress, style, }: KitChipProps) { - const { color, foreground, background, isCustomStyle } = useKitTheme(); + const { color, foreground, background } = useKitTheme(); return ( diff --git a/src/components/ui/kit/kit-fab.tsx b/src/components/ui/kit/kit-fab.tsx index 24c73371..8f5576b5 100644 --- a/src/components/ui/kit/kit-fab.tsx +++ b/src/components/ui/kit/kit-fab.tsx @@ -13,7 +13,7 @@ export function KitFab({ disabled = false, style, }: KitFabProps) { - const { color, foreground, background, border, shadow, isCustomStyle } = useKitTheme(); + const { color, foreground, background, border, shadow } = useKitTheme(); return ( @@ -78,7 +78,7 @@ export function KitListItem({ const content = ( <> - {leading ? {leading} : null} + {leading ? {leading} : null} {title ? ( @@ -87,7 +87,7 @@ export function KitListItem({ ) : null} {children} - {accessory ? {accessory} : null} + {accessory ? {accessory} : null} ); diff --git a/src/components/ui/kit/kit-surface.tsx b/src/components/ui/kit/kit-surface.tsx index 8f2b4270..09045c3d 100644 --- a/src/components/ui/kit/kit-surface.tsx +++ b/src/components/ui/kit/kit-surface.tsx @@ -5,7 +5,7 @@ import { BrandRadius } from "@/constants/brand"; import { useKitTheme } from "./use-kit-theme"; -export type KitSurfaceTone = "base" | "elevated" | "glass" | "sunken"; +export type KitSurfaceTone = "base" | "elevated" | "glass" | "sheet" | "sunken"; export type KitSurfaceProps = ViewProps & { tone?: KitSurfaceTone; @@ -46,9 +46,9 @@ export function KitSurface({ style, ...rest }: KitSurfaceProps) { - const { background, scheme, stylePreference } = useKitTheme(); + const { background, scheme } = useKitTheme(); const isGlass = tone === "glass"; - const allowNativeGlass = stylePreference === "native" && process.env.EXPO_OS === "ios"; + const allowNativeGlass = process.env.EXPO_OS === "ios"; const glassModule = allowNativeGlass ? getGlassModule() : null; const baseStyle = [ @@ -61,11 +61,15 @@ export function KitSurface({ ? { backgroundColor: background.surfaceElevated, } - : tone === "sunken" - ? { backgroundColor: background.panel } - : isGlass - ? { backgroundColor: background.glass } - : { backgroundColor: background.panel }), + : tone === "sheet" + ? { + backgroundColor: background.sheet, + } + : tone === "sunken" + ? { backgroundColor: background.panel } + : isGlass + ? { backgroundColor: background.glass } + : { backgroundColor: background.panel }), }, style, ]; diff --git a/src/components/ui/kit/use-kit-theme.ts b/src/components/ui/kit/use-kit-theme.ts index 1f491de7..ee7c50d1 100644 --- a/src/components/ui/kit/use-kit-theme.ts +++ b/src/components/ui/kit/use-kit-theme.ts @@ -32,8 +32,6 @@ function resolveColorValue(primary: unknown, fallback: unknown, final: ColorValu export type KitThemeTokens = { scheme: "light" | "dark"; - stylePreference: "native" | "custom"; - isCustomStyle: boolean; color: { primary: ColorValue; primaryPressed: ColorValue; @@ -44,6 +42,7 @@ export type KitThemeTokens = { }; background: { app: ColorValue; + sheet: ColorValue; surface: ColorValue; surfaceSecondary: ColorValue; surfaceElevated: ColorValue; @@ -84,22 +83,25 @@ export type KitThemeTokens = { }; export function useKitTheme() { - const { resolvedScheme: scheme, stylePreference } = useThemePreference(); + const { resolvedScheme: scheme } = useThemePreference(); const palette = useBrand(); return useMemo(() => { - const isCustomStyle = stylePreference === "custom"; const glassBackground = resolveAlphaColor( palette.surface as unknown, - isCustomStyle ? 0.94 : 0.88, + scheme === "dark" ? 0.9 : 0.84, palette.surfaceElevated as unknown, ); const panelBackground = resolveAlphaColor( palette.surfaceAlt as unknown, - scheme === "dark" ? 0.94 : 0.82, + scheme === "dark" ? 0.96 : 0.88, palette.surface as unknown, ); - const highlightBorder = TRANSPARENT; + const highlightBorder = resolveAlphaColor( + palette.borderStrong as unknown, + scheme === "dark" ? 0.45 : 0.36, + palette.border as unknown, + ); const primaryLiftShadow = "none"; const surfaceShadow = "none"; const switchTrackOff = resolveAlphaColor( @@ -115,8 +117,6 @@ export function useKitTheme() { return { scheme, - stylePreference, - isCustomStyle, color: { primary: palette.primary, primaryPressed: palette.primaryPressed, @@ -127,6 +127,7 @@ export function useKitTheme() { }, background: { app: palette.appBg, + sheet: palette.surfaceAlt, surface: palette.surface, surfaceSecondary: palette.surfaceAlt, surfaceElevated: palette.surfaceElevated, @@ -145,8 +146,16 @@ export function useKitTheme() { danger: palette.danger, }, border: { - primary: TRANSPARENT, - secondary: TRANSPARENT, + primary: resolveAlphaColor( + palette.borderStrong as unknown, + scheme === "dark" ? 0.4 : 0.28, + palette.border as unknown, + ), + secondary: resolveAlphaColor( + palette.border as unknown, + scheme === "dark" ? 0.28 : 0.18, + palette.border as unknown, + ), highlight: highlightBorder, transparent: TRANSPARENT, }, @@ -165,5 +174,5 @@ export function useKitTheme() { defaultTint: resolveStringColor(palette.primary, palette.text, palette.onPrimary), }, }; - }, [palette, scheme, stylePreference]); + }, [palette, scheme]); } diff --git a/src/constants/brand.ts b/src/constants/brand.ts index 0c55e2c8..fc854cf9 100644 --- a/src/constants/brand.ts +++ b/src/constants/brand.ts @@ -1,4 +1,3 @@ -import { Color } from "expo-router"; import type { ColorValue } from "react-native"; import { Platform } from "react-native"; import { FEATURE_FLAGS } from "@/constants/feature-flags"; @@ -10,8 +9,6 @@ import { type ThemeSeed, } from "@/constants/theme-generation"; -import type { ThemeStylePreference } from "@/lib/theme-preference"; - export type ResolvedBrandScheme = "light" | "dark"; type CalendarSwatch = { background: string; title: string }; @@ -49,272 +46,93 @@ export type BrandPalette = { generated?: GeneratedThemeTokens; }; -// Native-first semantic color palette. -// On iOS this uses system adaptive colors. -// On Android this uses Material 3 dynamic colors. -// On web/fallback this uses static values. -export const NativeBrand: BrandPalette = { - appBg: Platform.select({ - ios: Color.ios.systemGroupedBackground, - android: Color.android.dynamic.background, - default: "#f2f5f2", - }), - surface: Platform.select({ - ios: Color.ios.secondarySystemGroupedBackground, - android: Color.android.dynamic.surface, - default: "#ffffff", - }), - surfaceAlt: Platform.select({ - ios: Color.ios.tertiarySystemGroupedBackground, - android: Color.android.dynamic.surfaceVariant, - default: "#ecf1ec", - }), - surfaceElevated: Platform.select({ - ios: Color.ios.systemBackground, - android: Color.android.dynamic.surface, - default: "#f7faf7", - }), - border: Platform.select({ - ios: Color.ios.separator, - android: Color.android.dynamic.outlineVariant, - default: "#d3dbd3", - }), - borderStrong: Platform.select({ - ios: Color.ios.opaqueSeparator, - android: Color.android.dynamic.outline, - default: "#b6c3b6", - }), - text: Platform.select({ - ios: Color.ios.label, - android: Color.android.dynamic.onBackground, - default: "#131913", - }), - textMuted: Platform.select({ - ios: Color.ios.secondaryLabel, - android: Color.android.dynamic.onSurfaceVariant, - default: "#5b685b", - }), - textMicro: Platform.select({ - ios: Color.ios.tertiaryLabel, - android: Color.android.dynamic.onSurfaceVariant, - default: "#879387", - }), - primary: Platform.select({ - ios: Color.ios.systemGreen, - android: Color.android.dynamic.primary, - default: "#9be12c", - }), - primaryPressed: Platform.select({ - ios: Color.ios.systemGreen, - android: Color.android.dynamic.primary, - default: "#84c120", - }), - primarySubtle: Platform.select({ - ios: Color.ios.systemMint, - android: Color.android.dynamic.primaryContainer, - default: "#e9f8d1", - }), - onPrimary: Platform.select({ - ios: "#ffffff", - android: Color.android.dynamic.onPrimary, - default: "#ffffff", - }), - success: Platform.select({ - ios: Color.ios.systemGreen, - android: Color.android.attr.colorSuccess, - default: "#168a4a", - }) as string, - successSubtle: Platform.select({ - ios: Color.ios.systemMint, - android: Color.android.attr.colorSuccessContainer, - default: "#d4f0e2", - }) as string, - danger: Platform.select({ - ios: Color.ios.systemRed, - android: Color.android.dynamic.error, - default: "#c5333e", - }), - dangerSubtle: Platform.select({ - ios: Color.ios.systemPink, - android: Color.android.dynamic.errorContainer, - default: "#fce8ea", - }), - warning: Platform.select({ - ios: Color.ios.systemOrange, - android: Color.android.attr.colorWarning, - default: "#996b00", - }) as string, - warningSubtle: Platform.select({ - ios: Color.ios.systemYellow, - android: Color.android.attr.colorWarningContainer, - default: "#fff3cc", - }) as string, - focusRing: Platform.select({ - ios: Color.ios.systemGreen, - android: Color.android.dynamic.primary, - default: "#96d92f", - }), - tabBar: Platform.select({ - ios: "transparent", - android: Color.android.dynamic.surfaceContainer, - default: "#ffffff", - }), - tabBarBorder: Platform.select({ - ios: "transparent", - android: Color.android.dynamic.outlineVariant, - default: "#c9d5c9", - }), - calendar: { - accent: Platform.select({ - ios: Color.ios.systemPink, - android: Color.android.dynamic.tertiary, - default: "#f6118f", - }) as string, - accentSubtle: Platform.select({ - ios: Color.ios.systemPink, - android: Color.android.dynamic.tertiaryContainer, - default: "#fce8ea", - }) as string, - eventSwatches: [ - { - background: Platform.select({ - ios: Color.ios.systemMint, - android: Color.android.dynamic.primaryContainer, - default: "#e9f8d1", - }) as string, - title: Platform.select({ - ios: Color.ios.systemGreen, - android: Color.android.dynamic.onPrimaryContainer, - default: "#4f6d12", - }) as string, - }, - { - background: Platform.select({ - ios: Color.ios.systemMint, - android: Color.android.attr.colorSuccessContainer, - default: "#d4f0e2", - }) as string, - title: Platform.select({ - ios: Color.ios.systemGreen, - android: Color.android.attr.colorSuccess, - default: "#168a4a", - }) as string, - }, - { - background: Platform.select({ - ios: Color.ios.systemYellow, - android: Color.android.attr.colorWarningContainer, - default: "#fff3cc", - }) as string, - title: Platform.select({ - ios: Color.ios.systemOrange, - android: Color.android.attr.colorWarning, - default: "#996b00", - }) as string, - }, - { - background: Platform.select({ - ios: Color.ios.systemPink, - android: Color.android.dynamic.errorContainer, - default: "#fce8ea", - }) as string, - title: Platform.select({ - ios: Color.ios.systemRed, - android: Color.android.dynamic.onErrorContainer, - default: "#c5333e", - }) as string, - }, - ], - }, -}; - const CustomSeed: Record = { light: { - primary: "#FF5A1F", // Kinetic orange - background: "#FFF6EE", // Warm chalk - neutral: "#F1E4D6", // Warm athletic neutral + primary: "#6F7A58", // Muted olive + background: "#F6F1E8", // Warm cream + neutral: "#E8DED0", // Soft beige neutral success: "#16A34A", warning: "#D97706", danger: "#DC2626", - accent: "#0F7BFF", // Sport-tech blue + accent: "#556B7B", // Restrained slate accent }, dark: { - primary: "#FF6A2D", // Heated orange - background: "#0C0A08", // Warm ink black - neutral: "#18130F", // Warm dark neutral + primary: "#98A57E", // Muted sage + background: "#171410", // Warm charcoal + neutral: "#26211B", // Warm dark neutral success: "#22C55E", warning: "#F59E0B", danger: "#EF4444", - accent: "#46A1FF", // Brighter electric blue + accent: "#8EA0AD", // Soft steel }, }; const StaticCustomBrand: Record = { light: { - appBg: "#FFFFFF", - surface: "#FFFFFF", - surfaceAlt: "#F5F5F5", + appBg: "#F7F1E8", + surface: "#FFFDF9", + surfaceAlt: "#EFE5D7", surfaceElevated: "#FFFFFF", - border: "#E5E5E5", - borderStrong: "#D4D4D4", - text: "#0A0A0A", - textMuted: "#737373", - textMicro: "#A3A3A3", - primary: "#FF4D00", - primaryPressed: "#E64500", - primarySubtle: "#FFF0E5", - onPrimary: "#FFFFFF", + border: "#D9CCBA", + borderStrong: "#C8B8A2", + text: "#231F1A", + textMuted: "#73695D", + textMicro: "#9A8F82", + primary: "#6F7A58", + primaryPressed: "#616B4C", + primarySubtle: "#E2E6D8", + onPrimary: "#FBFAF7", success: "#16A34A", successSubtle: "#DCFCE7", danger: "#DC2626", dangerSubtle: "#FEE2E2", warning: "#D97706", warningSubtle: "#FEF3C7", - focusRing: "#FF6A21", - tabBar: "#FFFFFF", - tabBarBorder: "#E5E5E5", + focusRing: "#7B8761", + tabBar: "#F9F4EB", + tabBarBorder: "#D9CCBA", calendar: { - accent: "#FF4D00", - accentSubtle: "#FFF0E5", + accent: "#6F7A58", + accentSubtle: "#E2E6D8", eventSwatches: [ - { background: "#FFF0E5", title: "#CC3D00" }, - { background: "#DCFCE7", title: "#047857" }, - { background: "#FEF3C7", title: "#B45309" }, - { background: "#FEE2E2", title: "#BE123C" }, + { background: "#E2E6D8", title: "#556242" }, + { background: "#E8F2E8", title: "#25633B" }, + { background: "#F8E8C8", title: "#9A650B" }, + { background: "#F4DFDF", title: "#A43737" }, ], }, }, dark: { - appBg: "#000000", - surface: "#111111", - surfaceAlt: "#1A1A1A", - surfaceElevated: "#171717", - border: "#262626", - borderStrong: "#404040", - text: "#FAFAFA", - textMuted: "#A3A3A3", - textMicro: "#737373", - primary: "#FF5500", - primaryPressed: "#FF6A21", - primarySubtle: "#331100", - onPrimary: "#FFFFFF", + appBg: "#181410", + surface: "#201B16", + surfaceAlt: "#2A241E", + surfaceElevated: "#241F19", + border: "#3A3025", + borderStrong: "#504335", + text: "#F5F0E7", + textMuted: "#C1B4A6", + textMicro: "#8E8174", + primary: "#98A57E", + primaryPressed: "#889472", + primarySubtle: "#33402B", + onPrimary: "#13110E", success: "#22C55E", successSubtle: "#052E16", danger: "#EF4444", dangerSubtle: "#450A0A", warning: "#F59E0B", warningSubtle: "#451A03", - focusRing: "#FF5500", - tabBar: "#000000", - tabBarBorder: "#262626", + focusRing: "#A5B38A", + tabBar: "#191511", + tabBarBorder: "#3A3025", calendar: { - accent: "#FF5500", - accentSubtle: "#331100", + accent: "#98A57E", + accentSubtle: "#33402B", eventSwatches: [ - { background: "#331100", title: "#FF5500" }, - { background: "#052E16", title: "#4ADE80" }, - { background: "#451A03", title: "#FBBF24" }, - { background: "#450A0A", title: "#F87171" }, + { background: "#33402B", title: "#D5DEC1" }, + { background: "#10311D", title: "#61D38D" }, + { background: "#50330E", title: "#F6C45F" }, + { background: "#4D1818", title: "#F29090" }, ], }, }, @@ -398,73 +216,38 @@ function maybeWrapDeprecatedTokenWarnings(palette: BrandPalette): BrandPalette { }); } -const NativeMapBrandPalette = { - light: { - styleBackground: "#edf2ee", - zoneOutline: "#5e7565", - zoneOutlineOpacity: 0.6, - previewFill: "#b4ea78", - previewFillOpacity: 0.24, - previewOutline: "#7abf2a", - previewOutlineOpacity: 0.68, - selectedOutline: "#6fa722", - selectedOutlineOpacity: 1.0, - surfaceAlt: "#e8eee9", - primary: "#98db2a", - text: "#141a15", - }, - dark: { - styleBackground: "#171c18", - zoneOutline: "#607562", - zoneOutlineOpacity: 0.72, - previewFill: "#395526", - previewFillOpacity: 0.3, - previewOutline: "#9ce63d", - previewOutlineOpacity: 0.8, - selectedOutline: "#b8ff4a", - selectedOutlineOpacity: 1.0, - surfaceAlt: "#1f2721", - primary: "#b8ff4a", - text: "#f2f6f2", - }, -} as const; - const CustomMapBrandPalette = { light: { - styleBackground: "#edf2ee", - zoneOutline: "#5f7564", + styleBackground: "#f1ebe2", + zoneOutline: "#817561", zoneOutlineOpacity: 0.64, - previewFill: "#c9ef98", + previewFill: "#dfe5d1", previewFillOpacity: 0.25, - previewOutline: "#84be34", + previewOutline: "#7b8761", previewOutlineOpacity: 0.72, - selectedOutline: "#6da826", + selectedOutline: "#69744f", selectedOutlineOpacity: 1.0, - surfaceAlt: "#e7eee8", - primary: "#a7f20f", - text: "#131915", + surfaceAlt: "#efe5d7", + primary: "#6f7a58", + text: "#231f1a", }, dark: { - styleBackground: "#141915", - zoneOutline: "#637864", + styleBackground: "#1c1813", + zoneOutline: "#887a66", zoneOutlineOpacity: 0.74, - previewFill: "#385424", + previewFill: "#394430", previewFillOpacity: 0.3, - previewOutline: "#a7f20f", + previewOutline: "#a5b38a", previewOutlineOpacity: 0.84, - selectedOutline: "#c7ff39", + selectedOutline: "#c3d1a5", selectedOutlineOpacity: 1.0, - surfaceAlt: "#202922", - primary: "#c7ff39", - text: "#f1f6f1", + surfaceAlt: "#2a241e", + primary: "#98a57e", + text: "#f5f0e7", }, } as const; -export function getBrandPalette( - stylePreference: ThemeStylePreference, - scheme: ResolvedBrandScheme, -): BrandPalette { - void stylePreference; +export function getBrandPalette(scheme: ResolvedBrandScheme): BrandPalette { let custom: BrandPalette; try { custom = buildGeneratedCustomBrand(scheme); @@ -475,18 +258,10 @@ export function getBrandPalette( return maybeWrapDeprecatedTokenWarnings(custom); } -export function getMapBrandPalette( - stylePreference: ThemeStylePreference, - scheme: ResolvedBrandScheme, -) { - return stylePreference === "custom" - ? CustomMapBrandPalette[scheme] - : NativeMapBrandPalette[scheme]; +export function getMapBrandPalette(scheme: ResolvedBrandScheme) { + return CustomMapBrandPalette[scheme]; } -export const Brand = getBrandPalette("custom", "light"); -export const MapBrandPalette = NativeMapBrandPalette; - export function getThemeTokenAliasMap() { return getTokenAliasMap(); } @@ -505,55 +280,55 @@ export const BrandShadow = { export const BrandType = { display: { - fontFamily: "Sekuya-Regular", - fontSize: 54, - fontWeight: "400" as const, - letterSpacing: -1.0, - lineHeight: 58, + fontFamily: "Rubik_700Bold", + fontSize: 42, + fontWeight: "700" as const, + letterSpacing: -0.8, + lineHeight: 46, }, heading: { - fontFamily: "BarlowCondensed_700Bold", - fontSize: 34, - fontWeight: "500" as const, - letterSpacing: -0.5, - lineHeight: 38, + fontFamily: "Rubik_600SemiBold", + fontSize: 28, + fontWeight: "600" as const, + letterSpacing: -0.45, + lineHeight: 34, }, title: { fontFamily: "Rubik_600SemiBold", - fontSize: 21, - fontWeight: "500" as const, - letterSpacing: -0.3, - lineHeight: 27, + fontSize: 20, + fontWeight: "600" as const, + letterSpacing: -0.24, + lineHeight: 26, }, body: { fontFamily: "Rubik_400Regular", fontSize: 16, fontWeight: "400" as const, - lineHeight: 23, + lineHeight: 22, }, bodyMedium: { fontFamily: "Rubik_500Medium", fontSize: 16, - fontWeight: "400" as const, - lineHeight: 23, + fontWeight: "500" as const, + lineHeight: 22, }, bodyStrong: { - fontFamily: "Rubik_500Medium", + fontFamily: "Rubik_600SemiBold", fontSize: 16, - fontWeight: "500" as const, - lineHeight: 23, + fontWeight: "600" as const, + lineHeight: 22, }, caption: { fontFamily: "Rubik_400Regular", fontSize: 14, fontWeight: "400" as const, - lineHeight: 20, + lineHeight: 19, }, micro: { fontFamily: "Rubik_500Medium", fontSize: 12, fontWeight: "500" as const, - letterSpacing: 0.5, + letterSpacing: 0.2, lineHeight: 16, }, } as const; @@ -569,24 +344,24 @@ export const BrandSpacing = { export const BrandFonts = Platform.select({ ios: { - display: "Sekuya-Regular", - heading: "BarlowCondensed_700Bold", + display: "Rubik_700Bold", + heading: "Rubik_600SemiBold", body: "Rubik_400Regular", bodyMedium: "Rubik_500Medium", bodyStrong: "Rubik_600SemiBold", mono: "ui-monospace", }, default: { - display: "Sekuya-Regular", - heading: "BarlowCondensed_700Bold", + display: "Rubik_700Bold", + heading: "Rubik_600SemiBold", body: "Rubik_400Regular", bodyMedium: "Rubik_500Medium", bodyStrong: "Rubik_600SemiBold", mono: "monospace", }, web: { - display: "Sekuya-Regular", - heading: "BarlowCondensed_700Bold", + display: "Rubik_700Bold", + heading: "Rubik_600SemiBold", body: "Rubik_400Regular", bodyMedium: "Rubik_500Medium", bodyStrong: "Rubik_600SemiBold", diff --git a/src/contexts/system-ui-context.tsx b/src/contexts/system-ui-context.tsx index 96fe3359..4932301f 100644 --- a/src/contexts/system-ui-context.tsx +++ b/src/contexts/system-ui-context.tsx @@ -8,28 +8,39 @@ import { } from "react"; import type { ColorValue } from "react-native"; +export type InsetTone = "app" | "sheet" | "card" | "transparent"; + type SystemUiContextValue = { + topInsetTone: InsetTone; topInsetBackgroundColor: ColorValue | null; + setTopInsetTone: (tone: InsetTone) => void; setTopInsetBackgroundColor: (color: ColorValue | null) => void; }; const SystemUiContext = createContext(null); export function SystemUiProvider({ children }: PropsWithChildren) { + const [topInsetTone, setTopInsetToneState] = useState("app"); const [topInsetBackgroundColor, setTopInsetBackgroundColorState] = useState( null, ); + const setTopInsetTone = useCallback((tone: InsetTone) => { + setTopInsetToneState(tone); + }, []); + const setTopInsetBackgroundColor = useCallback((color: ColorValue | null) => { setTopInsetBackgroundColorState(color); }, []); const value = useMemo( () => ({ + topInsetTone, topInsetBackgroundColor, + setTopInsetTone, setTopInsetBackgroundColor, }), - [topInsetBackgroundColor, setTopInsetBackgroundColor], + [topInsetBackgroundColor, topInsetTone, setTopInsetBackgroundColor, setTopInsetTone], ); return {children}; diff --git a/src/contexts/tab-bar-scroll-context.tsx b/src/contexts/tab-bar-scroll-context.tsx deleted file mode 100644 index 48224449..00000000 --- a/src/contexts/tab-bar-scroll-context.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { createContext, useCallback, useContext, useMemo, useRef } from "react"; - -type ScrollDirection = "up" | "down" | "idle"; - -export type TabBarScrollSignal = { - routeKey: string; - offset: number; - direction: ScrollDirection; - velocity: number; - at: number; -}; - -type TabBarScrollContextValue = { - publishSignal: (signal: TabBarScrollSignal) => void; - getSignal: (routeKey: string) => TabBarScrollSignal | null; -}; - -const TabBarScrollContext = createContext(null); - -export function TabBarScrollProvider({ children }: { children: React.ReactNode }) { - const signalMapRef = useRef>(new Map()); - - const publishSignal = useCallback((signal: TabBarScrollSignal) => { - signalMapRef.current.set(signal.routeKey, signal); - }, []); - - const getSignal = useCallback((routeKey: string) => { - return signalMapRef.current.get(routeKey) ?? null; - }, []); - - const value = useMemo( - () => ({ publishSignal, getSignal }), - [getSignal, publishSignal], - ); - - return {children}; -} - -export function useTabBarScrollContext() { - const context = useContext(TabBarScrollContext); - if (!context) { - throw new Error("useTabBarScrollContext must be used within TabBarScrollProvider"); - } - return context; -} diff --git a/src/hooks/use-app-insets.ts b/src/hooks/use-app-insets.ts index 0c3a0e83..a87f16cc 100644 --- a/src/hooks/use-app-insets.ts +++ b/src/hooks/use-app-insets.ts @@ -1,8 +1,5 @@ -import { Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; - -const OVERLAY_GAP = Platform.OS === "ios" ? 16 : 18; -const ESTIMATED_NATIVE_TAB_BAR_HEIGHT = Platform.OS === "ios" ? 52 : 64; +import { BrandSpacing } from "@/constants/brand"; export type AppInsets = { safeTop: number; @@ -16,10 +13,10 @@ export function useAppInsets(): AppInsets { const safeTop = insets.top; const safeBottom = insets.bottom; - // NativeTabs applies safe-area handling, but floating UI and manual content padding still need - // to stay visually above the persistent native tab bar. - const tabContentBottom = safeBottom + ESTIMATED_NATIVE_TAB_BAR_HEIGHT; - const overlayBottom = tabContentBottom + OVERLAY_GAP; + // Native tab bars handle most insetting for normal content. We only add a shared clearance + // token for floating controls and screens that need breathing room above the bottom chrome. + const tabContentBottom = safeBottom + BrandSpacing.xxl; + const overlayBottom = tabContentBottom + BrandSpacing.sm; return { safeTop, diff --git a/src/hooks/use-brand.ts b/src/hooks/use-brand.ts index 50d4504c..ffba8084 100644 --- a/src/hooks/use-brand.ts +++ b/src/hooks/use-brand.ts @@ -13,9 +13,6 @@ import { useThemePreference } from "@/hooks/use-theme-preference"; * sets at the hook level. */ export function useBrand() { - const { resolvedScheme, stylePreference } = useThemePreference(); - return useMemo( - () => getBrandPalette(stylePreference, resolvedScheme), - [resolvedScheme, stylePreference], - ); + const { resolvedScheme } = useThemePreference(); + return useMemo(() => getBrandPalette(resolvedScheme), [resolvedScheme]); } diff --git a/src/hooks/use-layout-breakpoint.ts b/src/hooks/use-layout-breakpoint.ts new file mode 100644 index 00000000..592d2032 --- /dev/null +++ b/src/hooks/use-layout-breakpoint.ts @@ -0,0 +1,20 @@ +import { useWindowDimensions } from "react-native"; + +export const LAYOUT_BREAKPOINTS = { + desktopFrame: 1100, + desktopWide: 1180, + desktopExpanded: 1380, +} as const; + +export function useLayoutBreakpoint() { + const { width } = useWindowDimensions(); + const isWeb = process.env.EXPO_OS === "web"; + + return { + isWideFrame: isWeb && width >= LAYOUT_BREAKPOINTS.desktopFrame, + isDesktopWeb: isWeb && width >= LAYOUT_BREAKPOINTS.desktopWide, + isExpandedWeb: isWeb && width >= LAYOUT_BREAKPOINTS.desktopExpanded, + screenWidth: width, + isWeb, + }; +} diff --git a/src/hooks/use-tab-bar-scroll-signals.ts b/src/hooks/use-tab-bar-scroll-signals.ts deleted file mode 100644 index 4f240139..00000000 --- a/src/hooks/use-tab-bar-scroll-signals.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useCallback, useMemo, useRef } from "react"; -import type { NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps } from "react-native"; - -import { type TabBarScrollSignal, useTabBarScrollContext } from "@/contexts/tab-bar-scroll-context"; - -type ScrollDirection = TabBarScrollSignal["direction"]; - -function resolveDirection(delta: number): ScrollDirection { - if (Math.abs(delta) < 0.5) return "idle"; - return delta > 0 ? "down" : "up"; -} - -type UseTabBarScrollSignalsResult = { - onScroll: NonNullable; - getLatestSignal: () => TabBarScrollSignal | null; -}; - -export function useTabBarScrollSignals(routeKey: string): UseTabBarScrollSignalsResult { - const { publishSignal, getSignal } = useTabBarScrollContext(); - const normalizedRouteKey = routeKey.trim(); - const isRouteKeyValid = normalizedRouteKey.length > 0; - const previousOffsetRef = useRef(0); - const previousAtRef = useRef(null); - const previousDirectionRef = useRef("idle"); - const previousVelocityRef = useRef(0); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - if (!isRouteKeyValid) return; - const offset = Math.max(0, event.nativeEvent.contentOffset.y); - const now = Date.now(); - const previousOffset = previousOffsetRef.current; - const delta = offset - previousOffset; - const previousAt = previousAtRef.current; - const elapsedMs = previousAt === null ? 16 : Math.max(1, now - previousAt); - const velocity = Math.abs(delta) / elapsedMs; - const direction = resolveDirection(delta); - - previousOffsetRef.current = offset; - previousAtRef.current = now; - const previousDirection = previousDirectionRef.current; - const previousVelocity = previousVelocityRef.current; - const velocityDelta = Math.abs(velocity - previousVelocity); - const shouldPublish = - Math.abs(delta) >= 3 || - direction !== previousDirection || - velocityDelta >= 0.04 || - previousAt === null; - - if (!shouldPublish) { - return; - } - - previousDirectionRef.current = direction; - previousVelocityRef.current = velocity; - publishSignal({ - routeKey: normalizedRouteKey, - offset, - direction, - velocity, - at: now, - }); - }, - [isRouteKeyValid, normalizedRouteKey, publishSignal], - ); - - const getLatestSignal = useCallback( - () => (isRouteKeyValid ? getSignal(normalizedRouteKey) : null), - [getSignal, isRouteKeyValid, normalizedRouteKey], - ); - - return useMemo(() => ({ onScroll, getLatestSignal }), [getLatestSignal, onScroll]); -} diff --git a/src/hooks/use-theme-color.ts b/src/hooks/use-theme-color.ts deleted file mode 100644 index db416dca..00000000 --- a/src/hooks/use-theme-color.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useThemePreference } from "@/hooks/use-theme-preference"; - -/** - * Hook to select a color based on the current theme mode. - * - * NOTE: We avoid wrapping PlatformColor objects in plain JS objects as this - * breaks their internal "OpaqueColorValue" identity, causing native crashes. - */ -export function useThemeColor(props: { light?: any; dark?: any }) { - const { resolvedScheme } = useThemePreference(); - const theme = resolvedScheme ?? "light"; - return props[theme]; -} diff --git a/src/hooks/use-theme-preference.tsx b/src/hooks/use-theme-preference.tsx index 2ab13797..6171eb3a 100644 --- a/src/hooks/use-theme-preference.tsx +++ b/src/hooks/use-theme-preference.tsx @@ -6,29 +6,23 @@ import { applyThemePreference, loadThemePreference, persistThemePreference, - persistThemeStylePreference, type ThemePreference, - type ThemeStylePreference, } from "@/lib/theme-preference"; type ResolvedScheme = "light" | "dark"; type ThemePreferenceContextValue = { preference: ThemePreference; - stylePreference: ThemeStylePreference; resolvedScheme: ResolvedScheme; isReady: boolean; setPreference: (preference: ThemePreference) => Promise; - setStylePreference: (preference: ThemeStylePreference) => Promise; }; const DEFAULT_THEME_PREFERENCE_CONTEXT: ThemePreferenceContextValue = { preference: "system", - stylePreference: "custom", resolvedScheme: "light", isReady: false, setPreference: async () => undefined, - setStylePreference: async () => undefined, }; const ThemePreferenceContext = createContext( @@ -38,20 +32,16 @@ const ThemePreferenceContext = createContext( export function ThemePreferenceProvider({ children }: PropsWithChildren) { const systemScheme = useColorScheme(); const [preference, setPreferenceState] = useState("system"); - const [stylePreference, setStylePreferenceState] = useState("custom"); const [isReady, setIsReady] = useState(false); useEffect(() => { let mounted = true; const bootstrapPreference = async () => { - const [stored] = await Promise.all([loadThemePreference()]); + const stored = await loadThemePreference(); const nextPreference = stored ?? "system"; - const nextStylePreference: ThemeStylePreference = "custom"; applyThemePreference(nextPreference); if (!mounted) return; setPreferenceState(nextPreference); - setStylePreferenceState(nextStylePreference); - await persistThemeStylePreference("custom"); setIsReady(true); }; void bootstrapPreference(); @@ -66,26 +56,17 @@ export function ThemePreferenceProvider({ children }: PropsWithChildren) { await persistThemePreference(nextPreference); }, []); - const setStylePreference = useCallback(async (nextPreference: ThemeStylePreference) => { - const resolvedPreference: ThemeStylePreference = - nextPreference === "custom" ? "custom" : "custom"; - setStylePreferenceState(resolvedPreference); - await persistThemeStylePreference(resolvedPreference); - }, []); - const resolvedScheme: ResolvedScheme = preference === "system" ? (systemScheme ?? "light") : preference; const value = useMemo( () => ({ preference, - stylePreference, resolvedScheme, isReady, setPreference, - setStylePreference, }), - [isReady, preference, resolvedScheme, setPreference, setStylePreference, stylePreference], + [isReady, preference, resolvedScheme, setPreference], ); return ( diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 739bce11..68ae675e 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -41,7 +41,9 @@ const en = { failed: "Sync failed", }, timeline: { - noLessons: "No lessons", + noLessons: "No events", + allDay: "All day", + googleBadge: "Google", lifecycle: { live: "Live now", upcoming: "Upcoming", @@ -49,6 +51,10 @@ const en = { past: "Past", }, }, + filters: { + jobsOnly: "Jobs only", + jobsAndGoogle: "Jobs + Google", + }, footerHint: "Calendar interactions are enabled. Session events appear for instructor accounts.", }, tabsLayout: { @@ -193,6 +199,9 @@ const en = { applePermissionNote: "Apple sync requests calendar access and writes to a dedicated Queue Sessions calendar.", lastConnected: "Connected on {{date}}", + disconnectCleanupWarningTitle: "Google disconnect completed with warnings", + disconnectCleanupWarningBody: + "Queue removed the local connection, but some Queue-created Google events could not be deleted automatically.", actions: { connectGoogle: "Connect Google Calendar", disconnectGoogle: "Disconnect Google Calendar", diff --git a/src/i18n/translations/he.ts b/src/i18n/translations/he.ts index befa0d50..810a44b0 100644 --- a/src/i18n/translations/he.ts +++ b/src/i18n/translations/he.ts @@ -40,7 +40,9 @@ const he = { failed: "הסנכרון נכשל", }, timeline: { - noLessons: "אין שיעורים", + noLessons: "אין אירועים", + allDay: "כל היום", + googleBadge: "Google", lifecycle: { live: "בשידור חי", upcoming: "קרוב", @@ -48,6 +50,10 @@ const he = { past: "עבר", }, }, + filters: { + jobsOnly: "רק עבודות", + jobsAndGoogle: "עבודות + Google", + }, footerHint: "האינטראקציות ביומן פעילות. שיעורים יוצגו לחשבונות מדריך/ה.", }, tabsLayout: { @@ -115,16 +121,16 @@ const he = { darkMode: { title: "מצב כהה", description: "מעבר בין ערכת נושא בהירה לכהה.", - disableSystemFirst: "Disable System theme first to set a manual mode.", + disableSystemFirst: "בטלו קודם את מצב המערכת כדי לבחור מצב ידני.", }, themeStyle: { - title: "Theme style", - nativeDescription: "Use iOS/Android semantic dynamic colors.", - customDescription: "Use Queue custom brand colors across the app.", + title: "סגנון ערכת נושא", + nativeDescription: "שימוש בצבעים סמנטיים של iOS ו-Android.", + customDescription: "שימוש בשפת המותג של Queue בכל האפליקציה.", }, systemTheme: { - title: "System theme", - description: "Follow iOS/Android appearance automatically.", + title: "ערכת נושא של המערכת", + description: "התאמה אוטומטית למצב התצוגה של iOS או Android.", }, }, settings: { @@ -146,8 +152,8 @@ const he = { sports: { title: "תחומי ההדרכה שלכם", description: "בחרו את התחומים הפעילים שאתם מלמדים.", - none: "No sports selected", - selected: "{{count}} selected", + none: "לא נבחרו תחומים", + selected: "{{count}} נבחרו", }, location: { title: "מיקום ואזור", @@ -180,6 +186,9 @@ const he = { applePermissionNote: "סנכרון Apple יבקש הרשאת יומן וישמור אירועים ביומן Queue Sessions ייעודי.", lastConnected: "חובר בתאריך {{date}}", + disconnectCleanupWarningTitle: "ניתוק Google הושלם עם אזהרות", + disconnectCleanupWarningBody: + "Queue הסירה את החיבור המקומי, אבל לא הצליחה למחוק אוטומטית חלק מאירועי Google שנוצרו על ידי Queue.", actions: { connectGoogle: "חיבור Google Calendar", disconnectGoogle: "ניתוק Google Calendar", diff --git a/src/lib/theme-preference.ts b/src/lib/theme-preference.ts index e46b4a41..1e06e053 100644 --- a/src/lib/theme-preference.ts +++ b/src/lib/theme-preference.ts @@ -2,19 +2,13 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { Appearance } from "react-native"; const THEME_PREFERENCE_KEY = "app_theme_preference"; -const THEME_STYLE_PREFERENCE_KEY = "app_theme_style_preference"; export type ThemePreference = "light" | "dark" | "system"; -export type ThemeStylePreference = "native" | "custom"; function toThemePreference(value: string | null): ThemePreference | null { return value === "light" || value === "dark" || value === "system" ? value : null; } -function toThemeStylePreference(value: string | null): ThemeStylePreference | null { - return value === "native" || value === "custom" ? value : null; -} - export async function loadThemePreference(): Promise { try { const stored = await AsyncStorage.getItem(THEME_PREFERENCE_KEY); @@ -24,15 +18,6 @@ export async function loadThemePreference(): Promise { } } -export async function loadThemeStylePreference(): Promise { - try { - const stored = await AsyncStorage.getItem(THEME_STYLE_PREFERENCE_KEY); - return toThemeStylePreference(stored); - } catch { - return null; - } -} - export async function persistThemePreference(preference: ThemePreference): Promise { try { await AsyncStorage.setItem(THEME_PREFERENCE_KEY, preference); @@ -41,14 +26,6 @@ export async function persistThemePreference(preference: ThemePreference): Promi } } -export async function persistThemeStylePreference(preference: ThemeStylePreference): Promise { - try { - await AsyncStorage.setItem(THEME_STYLE_PREFERENCE_KEY, preference); - } catch { - // Ignore persistence failures - } -} - export function applyThemePreference(preference: ThemePreference): void { const setColorScheme = ( Appearance as { diff --git a/src/modules/app-shell/use-localization-bootstrap-prompt.ts b/src/modules/app-shell/use-localization-bootstrap-prompt.ts index a6734476..ea75730a 100644 --- a/src/modules/app-shell/use-localization-bootstrap-prompt.ts +++ b/src/modules/app-shell/use-localization-bootstrap-prompt.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { Alert, Platform } from "react-native"; +import { Alert, AppState, Platform } from "react-native"; import i18n, { bootstrapLocalization } from "@/i18n"; import { waitForInteractions } from "@/modules/app-shell/wait-for-interactions"; @@ -7,16 +7,22 @@ import { waitForInteractions } from "@/modules/app-shell/wait-for-interactions"; export function useLocalizationBootstrapPrompt() { useEffect(() => { let cancelled = false; - const run = async () => { + let alertShownForDirectionChange = false; + + const run = async (showRestartPrompt: boolean) => { try { const { directionChanged } = await bootstrapLocalization(); if (cancelled || !directionChanged || Platform.OS === "web") { return; } + if (!showRestartPrompt || alertShownForDirectionChange) { + return; + } await waitForInteractions(); if (cancelled) return; // Avoid forced runtime reload at startup; ask for manual restart instead. try { + alertShownForDirectionChange = true; Alert.alert( i18n.t("language.restartRequiredTitle"), i18n.t("language.restartRequiredMessage"), @@ -29,10 +35,18 @@ export function useLocalizationBootstrapPrompt() { } }; - void run(); + void run(true); + + const subscription = AppState.addEventListener("change", (nextState) => { + if (nextState !== "active" || Platform.OS === "web") { + return; + } + void run(false); + }); return () => { cancelled = true; + subscription.remove(); }; }, []); } diff --git a/src/modules/navigation/role-tabs-layout.tsx b/src/modules/navigation/role-tabs-layout.tsx index a12c10f2..cf455d5a 100644 --- a/src/modules/navigation/role-tabs-layout.tsx +++ b/src/modules/navigation/role-tabs-layout.tsx @@ -29,6 +29,7 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro diff --git a/src/modules/navigation/role-tabs-layout.web.tsx b/src/modules/navigation/role-tabs-layout.web.tsx index 0f6a192c..25051a8a 100644 --- a/src/modules/navigation/role-tabs-layout.web.tsx +++ b/src/modules/navigation/role-tabs-layout.web.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { KitPressable } from "@/components/ui/kit"; -import { TabBarScrollProvider } from "@/contexts/tab-bar-scroll-context"; import { useBrand } from "@/hooks/use-brand"; import { buildRoleTabRoute, type RoleTabRouteName } from "@/navigation/role-routes"; import { getTabsForRole } from "@/navigation/tab-registry"; @@ -48,296 +47,294 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro ); return ( - - + + + + + Queue Control + + + {appRole === "studio" ? "Studio dashboard" : "Instructor dashboard"} + + + Clean lanes, live priority, and less chrome. + + + - + + Active alerts + + + {String(totalAttention)} + + + Routed into one workspace instead of five repeated counters. + + + + + {tabs.map((tab) => { + const route = buildRoleTabRoute(appRole, tab.routeName) as Href; + const selected = activeTab?.id === tab.id; + const badgeCount = badgeCountByRoute[tab.routeName] ?? 0; + + return ( + + + + + + {t(tab.titleKey)} + + + {selected ? "Current workspace" : "Open workspace"} + + + {badgeCount > 0 ? ( + + + {badgeCount > 99 ? "99+" : String(badgeCount)} + + + ) : null} + + + + ); + })} + + + + + + - Queue Control + Workspace - {appRole === "studio" ? "Studio dashboard" : "Instructor dashboard"} - - - Clean lanes, live priority, and less chrome. + {t(activeTab?.titleKey ?? "tabs.home")} - Active alerts + Today - {String(totalAttention)} + {formatDashboardDate(locale)} - Routed into one workspace instead of five repeated counters. + Desktop mode is tuned for scanning, routing, and staying in flow. - - - {tabs.map((tab) => { - const route = buildRoleTabRoute(appRole, tab.routeName) as Href; - const selected = activeTab?.id === tab.id; - const badgeCount = badgeCountByRoute[tab.routeName] ?? 0; - - return ( - - - - - - {t(tab.titleKey)} - - - {selected ? "Current workspace" : "Open workspace"} - - - {badgeCount > 0 ? ( - - - {badgeCount > 99 ? "99+" : String(badgeCount)} - - - ) : null} - - - - ); - })} - - - - - - Workspace - - - {t(activeTab?.titleKey ?? "tabs.home")} - - - - - - Today - - - {formatDashboardDate(locale)} - - - Desktop mode is tuned for scanning, routing, and staying in flow. - - - - - - - + + - + ); } diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 1d0943f6..00000000 --- a/tailwind.config.js +++ /dev/null @@ -1,20 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - "./src/**/*.{js,jsx,ts,tsx}", - "./convex/**/*.{js,jsx,ts,tsx}", - ], - presets: [require("nativewind/preset")], - theme: { - extend: { - fontFamily: { - display: ["Sekuya-Regular"], - heading: ["BarlowCondensed_700Bold"], - body: ["Rubik_400Regular"], - bodyMedium: ["Rubik_500Medium"], - bodyStrong: ["Rubik_600SemiBold"], - }, - }, - }, - plugins: [], -}; diff --git a/tests/contracts/calendar-token-hardening.contract.test.ts b/tests/contracts/calendar-token-hardening.contract.test.ts index cacaf173..b9d32a0f 100644 --- a/tests/contracts/calendar-token-hardening.contract.test.ts +++ b/tests/contracts/calendar-token-hardening.contract.test.ts @@ -4,7 +4,7 @@ import { decryptCalendarToken, encryptCalendarToken, isEncryptedCalendarToken, -} from "../../convex/calendar"; +} from "../../convex/lib/calendarCrypto"; describe("calendar token hardening contracts", () => { it("keeps plaintext tokens readable when no encryption secret is configured", () => { diff --git a/tests/contracts/google-calendar-backend-sync.contract.test.ts b/tests/contracts/google-calendar-backend-sync.contract.test.ts new file mode 100644 index 00000000..a2c18bd3 --- /dev/null +++ b/tests/contracts/google-calendar-backend-sync.contract.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; + +import type { Id } from "../../convex/_generated/dataModel"; +import { getCalendarTimelineForUser } from "../../convex/calendar"; +import { InMemoryConvexDb } from "../in-memory-convex"; + +describe("google calendar backend sync contracts", () => { + it("loads instructor timeline rows by user id for scheduled syncs", async () => { + const db = new InMemoryConvexDb(); + const userId = (await db.insert("users", { + email: "coach@example.com", + role: "instructor", + isActive: true, + createdAt: Date.now(), + updatedAt: Date.now(), + })) as Id<"users">; + const instructorId = (await db.insert("instructorProfiles", { + userId, + displayName: "Coach One", + sports: ["tennis"], + zones: [], + sessionLanguages: ["en"], + requiredLevels: ["beginner"], + bio: "Coach", + experienceYears: 3, + hourlyRate: 150, + createdAt: Date.now(), + updatedAt: Date.now(), + })) as Id<"instructorProfiles">; + const studioId = (await db.insert("studioProfiles", { + userId: "users:studio-1", + studioName: "Baseline Studio", + createdAt: Date.now(), + updatedAt: Date.now(), + })) as Id<"studioProfiles">; + + await db.insert("jobs", { + studioId, + sport: "pilates", + zone: "tel-aviv", + startTime: 1_800_000_100_000, + endTime: 1_800_000_103_600, + pay: 220, + status: "cancelled", + filledByInstructorId: instructorId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + await db.insert("jobs", { + studioId, + sport: "tennis", + zone: "tel-aviv", + startTime: 1_800_000_000_000, + endTime: 1_800_000_003_600, + pay: 200, + status: "filled", + filledByInstructorId: instructorId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const rows = await (getCalendarTimelineForUser as any)._handler( + { db }, + { + userId, + startTime: 1_799_999_000_000, + endTime: 1_800_001_000_000, + limit: 20, + }, + ); + + expect(rows).toEqual([ + { + lessonId: "jobs:2", + roleView: "instructor", + studioName: "Baseline Studio", + instructorName: "Coach One", + sport: "tennis", + startTime: 1_800_000_000_000, + endTime: 1_800_000_003_600, + status: "filled", + }, + { + lessonId: "jobs:1", + roleView: "instructor", + studioName: "Baseline Studio", + instructorName: "Coach One", + sport: "pilates", + startTime: 1_800_000_100_000, + endTime: 1_800_000_103_600, + status: "cancelled", + }, + ]); + }); +}); diff --git a/tests/contracts/google-calendar-sync.contract.test.ts b/tests/contracts/google-calendar-sync.contract.test.ts new file mode 100644 index 00000000..ddb359dc --- /dev/null +++ b/tests/contracts/google-calendar-sync.contract.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; + +import { + isQueueManagedGoogleEvent, + normalizeImportedGoogleEvent, +} from "../../convex/lib/calendarShared"; + +describe("google calendar sync contracts", () => { + it("normalizes timed Google events into agenda rows", () => { + const event = normalizeImportedGoogleEvent({ + id: "google-event-1", + summary: "Pilates class", + status: "confirmed", + location: "Studio A", + htmlLink: "https://calendar.google.com/event?eid=123", + updated: "2026-03-10T08:15:00.000Z", + start: { + dateTime: "2026-03-12T09:00:00.000Z", + timeZone: "UTC", + }, + end: { + dateTime: "2026-03-12T10:00:00.000Z", + timeZone: "UTC", + }, + }); + + expect(event).toEqual({ + providerEventId: "google-event-1", + title: "Pilates class", + status: "confirmed", + startTime: Date.parse("2026-03-12T09:00:00.000Z"), + endTime: Date.parse("2026-03-12T10:00:00.000Z"), + isAllDay: false, + location: "Studio A", + htmlLink: "https://calendar.google.com/event?eid=123", + timeZone: "UTC", + providerUpdatedAt: Date.parse("2026-03-10T08:15:00.000Z"), + }); + }); + + it("normalizes all-day Google events using Google’s exclusive end date", () => { + const event = normalizeImportedGoogleEvent({ + id: "google-event-2", + summary: "Holiday", + start: { date: "2026-03-20" }, + end: { date: "2026-03-21" }, + }); + + expect(event).toEqual({ + providerEventId: "google-event-2", + title: "Holiday", + status: "confirmed", + startTime: Date.parse("2026-03-20"), + endTime: Date.parse("2026-03-21"), + isAllDay: true, + }); + }); + + it("treats Queue-managed Google events as import exclusions", () => { + expect( + isQueueManagedGoogleEvent( + { + id: "provider-event-1", + extendedProperties: { + private: { + queueSource: "queue-job", + }, + }, + }, + new Set(), + ), + ).toBe(true); + + expect( + isQueueManagedGoogleEvent( + { + id: "provider-event-2", + }, + new Set(["provider-event-2"]), + ), + ).toBe(true); + + expect( + isQueueManagedGoogleEvent( + { + id: "provider-event-3", + extendedProperties: { + private: { + queueSource: "other-system", + }, + }, + }, + new Set(), + ), + ).toBe(false); + }); +}); diff --git a/tests/contracts/integration-ops.contract.test.ts b/tests/contracts/integration-ops.contract.test.ts index 9d643ef8..9bc0def2 100644 --- a/tests/contracts/integration-ops.contract.test.ts +++ b/tests/contracts/integration-ops.contract.test.ts @@ -164,7 +164,6 @@ describe("integration event operator contracts", () => { process.env.INTEGRATION_EVENTS_ACCESS_TOKEN = originalToken; } }); - it("lists older failed events beyond the first fixed window", async () => { const originalToken = process.env.INTEGRATION_EVENTS_ACCESS_TOKEN; process.env.INTEGRATION_EVENTS_ACCESS_TOKEN = ACCESS_TOKEN; diff --git a/tests/contracts/phase3-webhooks-workflows.contract.test.ts b/tests/contracts/phase3-webhooks-workflows.contract.test.ts index 5f0ef301..c0f79f3d 100644 --- a/tests/contracts/phase3-webhooks-workflows.contract.test.ts +++ b/tests/contracts/phase3-webhooks-workflows.contract.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "bun:test"; +import { internal } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { processDiditWebhookEvent } from "../../convex/didit"; import { + closeJobIfStillOpen, + postJob, reviewApplication, runAcceptedApplicationReviewWorkflow, runRejectedApplicationReviewWorkflow, @@ -274,6 +277,8 @@ describe("phase-3 reviewApplication workflow parity contracts", () => { jobId, applicationA, applicationB, + instructorUserA, + instructorUserB, instructorA, instructorB, }; @@ -361,6 +366,22 @@ describe("phase-3 reviewApplication workflow parity contracts", () => { 30 * 60 * 1000, 90 * 60 * 1000, ]); + const calendarSyncCalls = schedulerCalls.filter( + (call) => + call.args && + typeof call.args === "object" && + "userId" in (call.args as Record) && + !("data" in (call.args as Record)) && + !("event" in (call.args as Record)), + ); + expect(calendarSyncCalls).toHaveLength(2); + expect(calendarSyncCalls.every((call) => call.delayMs === 0)).toBe(true); + expect(calendarSyncCalls.map((call) => call.args)).toContainEqual({ + userId: seeded.instructorUserA, + }); + expect(calendarSyncCalls.map((call) => call.args)).toContainEqual({ + userId: seeded.studioUserId, + }); expect(runMutationCalls).toHaveLength(1); expect( runMutationCalls[0] && @@ -468,4 +489,104 @@ describe("phase-3 reviewApplication workflow parity contracts", () => { restoreNow(); } }); + + it("create job schedules a Google Calendar sync for the posting studio", async () => { + const restoreNow = freezeNow(FIXED_NOW); + try { + const db = new InMemoryConvexDb(); + const studioUserId = (await db.insert("users", { + role: "studio", + onboardingComplete: true, + isActive: true, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + })) as Id<"users">; + await db.insert("studioProfiles", { + userId: studioUserId, + studioName: "Studio", + address: "Main st", + zone: "5001557", + notificationsEnabled: true, + autoExpireMinutesBefore: 30, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + }); + + const schedulerCalls: ScheduledCall[] = []; + const ctx = createMutationCtx({ + db, + userId: studioUserId, + schedulerCalls, + }); + + await expect( + (postJob as any)._handler(ctx, { + sport: "hiit", + startTime: FIXED_NOW + 2 * 60 * 60 * 1000, + endTime: FIXED_NOW + 3 * 60 * 60 * 1000, + pay: 250, + }), + ).resolves.toEqual({ + jobId: expect.any(String), + }); + + expect(schedulerCalls).toContainEqual({ + delayMs: 0, + fn: internal.calendar.syncGoogleCalendarForUser, + args: { userId: studioUserId }, + }); + } finally { + restoreNow(); + } + }); + + it("closing an open job schedules a Google Calendar removal sync for the studio", async () => { + const restoreNow = freezeNow(FIXED_NOW); + try { + const db = new InMemoryConvexDb(); + const studioUserId = (await db.insert("users", { + role: "studio", + onboardingComplete: true, + isActive: true, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + })) as Id<"users">; + const studioId = (await db.insert("studioProfiles", { + userId: studioUserId, + studioName: "Studio", + address: "Main st", + zone: "5001557", + notificationsEnabled: true, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + })) as Id<"studioProfiles">; + const jobId = (await db.insert("jobs", { + studioId, + zone: "5001557", + sport: "hiit", + startTime: FIXED_NOW - 2 * 60 * 60 * 1000, + endTime: FIXED_NOW - 60 * 60 * 1000, + pay: 250, + status: "open", + postedAt: FIXED_NOW - 3 * 60 * 60 * 1000, + })) as Id<"jobs">; + + const schedulerCalls: ScheduledCall[] = []; + const ctx = createMutationCtx({ db, schedulerCalls }); + + await expect((closeJobIfStillOpen as any)._handler(ctx, { jobId })).resolves.toEqual({ + updated: true, + }); + + const job = await db.get("jobs", jobId); + expect(job?.status).toBe("cancelled"); + expect(schedulerCalls).toContainEqual({ + delayMs: 0, + fn: internal.calendar.syncGoogleCalendarForUser, + args: { userId: studioUserId }, + }); + } finally { + restoreNow(); + } + }); }); diff --git a/tests/in-memory-convex.ts b/tests/in-memory-convex.ts index 0916a12b..6ff6994c 100644 --- a/tests/in-memory-convex.ts +++ b/tests/in-memory-convex.ts @@ -6,6 +6,7 @@ type AnyDoc = Record & { }; type EqCondition = { + kind: "eq" | "gte" | "lte"; field: string; value: unknown; }; @@ -14,6 +15,7 @@ class InMemoryQuery { private readonly table: AnyDoc[]; private readonly conditions: EqCondition[] = []; private sortDirection: "asc" | "desc" | null = null; + private sortField: string | null = null; constructor(table: AnyDoc[]) { this.table = table; @@ -21,11 +23,25 @@ class InMemoryQuery { withIndex( _indexName: string, - builder: (q: { eq(field: string, value: unknown): unknown }) => unknown, + builder: (q: { + eq(field: string, value: unknown): unknown; + gte(field: string, value: unknown): unknown; + lte(field: string, value: unknown): unknown; + }) => unknown, ) { const queryBuilder = { eq: (field: string, value: unknown) => { - this.conditions.push({ field, value }); + this.conditions.push({ kind: "eq", field, value }); + return queryBuilder; + }, + gte: (field: string, value: unknown) => { + this.conditions.push({ kind: "gte", field, value }); + this.sortField ??= field; + return queryBuilder; + }, + lte: (field: string, value: unknown) => { + this.conditions.push({ kind: "lte", field, value }); + this.sortField ??= field; return queryBuilder; }, }; @@ -65,12 +81,26 @@ class InMemoryQuery { private resolve() { let rows = this.table.filter((row) => - this.conditions.every((condition) => row[condition.field] === condition.value), + this.conditions.every((condition) => { + const left = row[condition.field]; + const right = condition.value; + if (condition.kind === "eq") { + return left === right; + } + if (left === undefined || left === null) { + return false; + } + if (condition.kind === "gte") { + return (left as any) >= (right as any); + } + return (left as any) <= (right as any); + }), ); if (this.sortDirection) { rows = [...rows].sort((a, b) => { - const left = Number(a._creationTime); - const right = Number(b._creationTime); + const sortField = this.sortField; + const left = Number(sortField ? a[sortField] : a._creationTime); + const right = Number(sortField ? b[sortField] : b._creationTime); return this.sortDirection === "asc" ? left - right : right - left; }); } diff --git a/tsconfig.json b/tsconfig.json index db56c93d..72a3c471 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,8 +22,7 @@ "convex/**/*.ts", "convex/**/*.tsx", ".expo/types/**/*.ts", - "expo-env.d.ts", - "nativewind-env.d.ts" + "expo-env.d.ts" ], "exclude": [ "node_modules",