diff --git a/frontend/app.json b/frontend/app.json index 5c52d0b..22004ff 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -17,6 +17,7 @@ "monochromeImage": "./assets/android-icon-monochrome.png" }, "predictiveBackGestureEnabled": false - } + }, + "plugins": ["expo-sqlite"] } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2628621..d81e430 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,17 +9,21 @@ "version": "1.0.0", "dependencies": { "expo": "~57.0.7", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-native": "0.86.0" }, "devDependencies": { "@types/react": "~19.2.2", + "@types/sql.js": "^1.4.11", "eslint": "^9.39.5", "eslint-config-expo": "^57.0.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.9.5", - "typescript": "~6.0.3" + "sql.js": "^1.14.1", + "typescript": "~6.0.3", + "vitest": "^4.1.10" }, "engines": { "node": ">=20.20.2 <21", @@ -1846,6 +1850,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.86.0", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz", @@ -2027,6 +2041,251 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2040,6 +2299,13 @@ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2051,6 +2317,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2115,6 +2406,17 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/sql.js": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", + "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2679,6 +2981,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -3008,6 +3423,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3034,6 +3459,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -3374,6 +3805,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4000,6 +4441,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4674,6 +5122,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4702,6 +5160,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expo": { "version": "57.0.7", "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.7.tgz", @@ -4818,6 +5286,20 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sqlite": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.1.tgz", + "integrity": "sha512-I6KoUvfGIiROTKxr5D3H+jRIGA/iEvWEtqHK4XMukAA7tVTinz/YdS8zOz6/DdG6vgrNmvF7gyOcbhLinlfxzQ==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", @@ -5443,6 +5925,21 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7072,6 +7569,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -7495,9 +8002,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -7753,6 +8260,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -8063,6 +8584,13 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8124,9 +8652,9 @@ } }, "node_modules/postcss": { - "version": "8.5.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", - "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -8143,7 +8671,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8563,6 +9091,39 @@ "node": ">=4" } }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -8931,6 +9492,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9000,6 +9568,13 @@ "node": ">=0.10.0" } }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "dev": true, + "license": "MIT" + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -9007,6 +9582,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -9034,6 +9616,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9294,6 +9883,23 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9339,6 +9945,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9725,6 +10341,200 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -9865,6 +10675,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1b2211c..ef12eb3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,17 +9,21 @@ "packageManager": "npm@10.8.2", "dependencies": { "expo": "~57.0.7", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-native": "0.86.0" }, "devDependencies": { "@types/react": "~19.2.2", + "@types/sql.js": "^1.4.11", "eslint": "^9.39.5", "eslint-config-expo": "^57.0.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.9.5", - "typescript": "~6.0.3" + "sql.js": "^1.14.1", + "typescript": "~6.0.3", + "vitest": "^4.1.10" }, "overrides": { "uuid": "^11.1.1" @@ -32,8 +36,9 @@ "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", + "test": "vitest run", "typecheck": "tsc --noEmit", - "check": "npm run lint && npm run format:check && npm run typecheck" + "check": "npm run lint && npm run format:check && npm run typecheck && npm run test" }, "private": true } diff --git a/frontend/src/features/schedule/data/index.ts b/frontend/src/features/schedule/data/index.ts new file mode 100644 index 0000000..7461aaf --- /dev/null +++ b/frontend/src/features/schedule/data/index.ts @@ -0,0 +1,9 @@ +export { + ScheduleLocalRepository, + type CloudScheduleRow, + type LocalReminderDispositionState, + type LocalReminderRuntimeUpdate, + type LocalReminderSyncStatus, + type LocalScheduleOccurrenceOverrideRow, + type LocalScheduleRow, +} from './local'; diff --git a/frontend/src/features/schedule/data/local/index.ts b/frontend/src/features/schedule/data/local/index.ts new file mode 100644 index 0000000..b162638 --- /dev/null +++ b/frontend/src/features/schedule/data/local/index.ts @@ -0,0 +1,9 @@ +export { + ScheduleLocalRepository, + type CloudScheduleRow, + type LocalReminderDispositionState, + type LocalReminderRuntimeUpdate, + type LocalReminderSyncStatus, + type LocalScheduleOccurrenceOverrideRow, + type LocalScheduleRow, +} from './scheduleLocalRepository'; diff --git a/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts new file mode 100644 index 0000000..7d189c2 --- /dev/null +++ b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts @@ -0,0 +1,256 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +import type { + ReminderDispositionState, + ReminderStrength, + ReminderSyncStatus, + ReminderType, +} from '../../../../contracts/reminder'; +import type { + OccurrenceOverrideAction, + ReminderDispositionState as CloudReminderDispositionState, + ScheduleKind, + ScheduleStatus, + ScheduleType, +} from '../../../../contracts/schedule'; + +export type LocalReminderDispositionState = ReminderDispositionState; +export type LocalReminderSyncStatus = ReminderSyncStatus; + +export interface CloudScheduleRow { + id: string; + account_id: string; + schedule_type: ScheduleType; + schedule_kind: ScheduleKind; + title: string; + is_all_day: 0 | 1; + start_time: string | null; + end_time: string | null; + timezone: string; + recurrence_rule: string | null; + location_name: string | null; + latitude: number | null; + longitude: number | null; + reminder_type: ReminderType | null; + reminder_trigger_at: string | null; + reminder_offset_minutes: number | null; + reminder_strength: ReminderStrength | null; + reminder_disposition_state: CloudReminderDispositionState | null; + status: ScheduleStatus; + cloud_revision: number; + updated_at: string; +} + +export interface LocalScheduleRow extends Omit { + reminder_disposition_state: LocalReminderDispositionState | null; + next_trigger_at: string | null; + snoozed_until: string | null; + geofence_armed: 0 | 1; + disposition_updated_at: string | null; + sync_status: LocalReminderSyncStatus; +} + +export type LocalReminderRuntimeUpdate = Pick< + LocalScheduleRow, + | 'reminder_disposition_state' + | 'next_trigger_at' + | 'snoozed_until' + | 'geofence_armed' + | 'disposition_updated_at' + | 'sync_status' +>; + +export interface LocalScheduleOccurrenceOverrideRow { + id: string; + schedule_id: string; + occurrence_start: string; + action: OccurrenceOverrideAction; + replacement_schedule_id: string | null; +} + +export class ScheduleLocalRepository { + public constructor(private readonly database: SQLiteDatabase) {} + + public getSchedule(accountId: string, scheduleId: string): Promise { + return this.database.getFirstAsync( + `SELECT * FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + scheduleId, + ); + } + + public listSchedules(accountId: string): Promise { + return this.database.getAllAsync( + `SELECT * + FROM local_schedules + WHERE account_id = ? + ORDER BY start_time, updated_at, id`, + accountId, + ); + } + + /** Apply cloud-owned fields while preserving existing device runtime state. */ + public async applyCloudSchedule(row: CloudScheduleRow): Promise { + const result = await this.database.runAsync( + `INSERT INTO local_schedules ( + id, account_id, schedule_type, schedule_kind, title, is_all_day, + start_time, end_time, timezone, recurrence_rule, location_name, + latitude, longitude, reminder_type, reminder_trigger_at, + reminder_offset_minutes, reminder_strength, reminder_disposition_state, + next_trigger_at, snoozed_until, geofence_armed, disposition_updated_at, + sync_status, status, cloud_revision, updated_at + ) VALUES ( + $id, $account_id, $schedule_type, $schedule_kind, $title, $is_all_day, + $start_time, $end_time, $timezone, $recurrence_rule, $location_name, + $latitude, $longitude, $reminder_type, $reminder_trigger_at, + $reminder_offset_minutes, $reminder_strength, $reminder_disposition_state, + NULL, NULL, 0, NULL, 'synced', $status, $cloud_revision, $updated_at + ) + ON CONFLICT(id) DO UPDATE SET + schedule_type = excluded.schedule_type, + schedule_kind = excluded.schedule_kind, + title = excluded.title, + is_all_day = excluded.is_all_day, + start_time = excluded.start_time, + end_time = excluded.end_time, + timezone = excluded.timezone, + recurrence_rule = excluded.recurrence_rule, + location_name = excluded.location_name, + latitude = excluded.latitude, + longitude = excluded.longitude, + reminder_type = excluded.reminder_type, + reminder_trigger_at = excluded.reminder_trigger_at, + reminder_offset_minutes = excluded.reminder_offset_minutes, + reminder_strength = excluded.reminder_strength, + status = excluded.status, + cloud_revision = excluded.cloud_revision, + updated_at = excluded.updated_at + WHERE local_schedules.account_id = excluded.account_id`, + { + $id: row.id, + $account_id: row.account_id, + $schedule_type: row.schedule_type, + $schedule_kind: row.schedule_kind, + $title: row.title, + $is_all_day: row.is_all_day, + $start_time: row.start_time, + $end_time: row.end_time, + $timezone: row.timezone, + $recurrence_rule: row.recurrence_rule, + $location_name: row.location_name, + $latitude: row.latitude, + $longitude: row.longitude, + $reminder_type: row.reminder_type, + $reminder_trigger_at: row.reminder_trigger_at, + $reminder_offset_minutes: row.reminder_offset_minutes, + $reminder_strength: row.reminder_strength, + $reminder_disposition_state: row.reminder_disposition_state, + $status: row.status, + $cloud_revision: row.cloud_revision, + $updated_at: row.updated_at, + }, + ); + return result.changes === 1; + } + + /** Update only reminder state owned by the current device. */ + public async updateReminderRuntime( + accountId: string, + scheduleId: string, + runtime: LocalReminderRuntimeUpdate, + ): Promise { + const result = await this.database.runAsync( + `UPDATE local_schedules SET + reminder_disposition_state = $reminder_disposition_state, + next_trigger_at = $next_trigger_at, + snoozed_until = $snoozed_until, + geofence_armed = $geofence_armed, + disposition_updated_at = $disposition_updated_at, + sync_status = $sync_status + WHERE account_id = $account_id AND id = $id`, + { + $account_id: accountId, + $id: scheduleId, + $reminder_disposition_state: runtime.reminder_disposition_state, + $next_trigger_at: runtime.next_trigger_at, + $snoozed_until: runtime.snoozed_until, + $geofence_armed: runtime.geofence_armed, + $disposition_updated_at: runtime.disposition_updated_at, + $sync_status: runtime.sync_status, + }, + ); + return result.changes === 1; + } + + /** Physically remove one local row for recovery or maintenance cleanup only. */ + public async purgeSchedule(accountId: string, scheduleId: string): Promise { + const result = await this.database.runAsync( + `DELETE FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + scheduleId, + ); + return result.changes === 1; + } + + public async upsertOccurrenceOverride( + accountId: string, + row: LocalScheduleOccurrenceOverrideRow, + ): Promise { + const owner = await this.database.getFirstAsync<{ id: string }>( + `SELECT id FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + row.schedule_id, + ); + if (owner === null) { + return false; + } + if (row.replacement_schedule_id !== null) { + const replacementOwner = await this.database.getFirstAsync<{ id: string }>( + `SELECT id FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + row.replacement_schedule_id, + ); + if (replacementOwner === null) { + return false; + } + } + + const result = await this.database.runAsync( + `INSERT INTO local_schedule_occurrence_overrides ( + id, schedule_id, occurrence_start, action, replacement_schedule_id + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + occurrence_start = excluded.occurrence_start, + action = excluded.action, + replacement_schedule_id = excluded.replacement_schedule_id + WHERE schedule_id = excluded.schedule_id`, + row.id, + row.schedule_id, + row.occurrence_start, + row.action, + row.replacement_schedule_id, + ); + return result.changes === 1; + } + + public listOccurrenceOverrides( + accountId: string, + scheduleId?: string, + ): Promise { + const scheduleFilter = scheduleId === undefined ? '' : 'AND overrides.schedule_id = ?'; + const parameters = scheduleId === undefined ? [accountId] : [accountId, scheduleId]; + return this.database.getAllAsync( + `SELECT + overrides.id, + overrides.schedule_id, + overrides.occurrence_start, + overrides.action, + overrides.replacement_schedule_id + FROM local_schedule_occurrence_overrides AS overrides + INNER JOIN local_schedules AS schedules ON schedules.id = overrides.schedule_id + WHERE schedules.account_id = ? ${scheduleFilter} + ORDER BY overrides.occurrence_start, overrides.id`, + parameters, + ); + } +} diff --git a/frontend/src/infrastructure/database/index.ts b/frontend/src/infrastructure/database/index.ts new file mode 100644 index 0000000..18151e7 --- /dev/null +++ b/frontend/src/infrastructure/database/index.ts @@ -0,0 +1,6 @@ +export { + CREATE_SCHEDULE_STORAGE_SQL, + CURRENT_DATABASE_VERSION, + migrateScheduleDatabase, +} from './migrations'; +export { openTimeflowDatabase, TIMEFLOW_DATABASE_NAME } from './sqlite'; diff --git a/frontend/src/infrastructure/database/migrations.ts b/frontend/src/infrastructure/database/migrations.ts new file mode 100644 index 0000000..4fceb79 --- /dev/null +++ b/frontend/src/infrastructure/database/migrations.ts @@ -0,0 +1,134 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +export const CURRENT_DATABASE_VERSION = 1; + +export const CREATE_SCHEDULE_STORAGE_SQL = ` +CREATE TABLE IF NOT EXISTS local_schedules ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + schedule_type TEXT NOT NULL CHECK (schedule_type IN ('time', 'location')), + schedule_kind TEXT NOT NULL DEFAULT 'once' CHECK (schedule_kind IN ('once', 'recurring')), + title TEXT NOT NULL, + is_all_day INTEGER NOT NULL DEFAULT 0 CHECK (is_all_day IN (0, 1)), + start_time TEXT NULL, + end_time TEXT NULL, + timezone TEXT NOT NULL, + recurrence_rule TEXT NULL, + location_name TEXT NULL, + latitude REAL NULL CHECK (latitude IS NULL OR latitude BETWEEN -90 AND 90), + longitude REAL NULL CHECK (longitude IS NULL OR longitude BETWEEN -180 AND 180), + reminder_type TEXT NULL CHECK ( + reminder_type IS NULL OR reminder_type IN ( + 'at_time', + 'before_start', + 'arrive_location', + 'return_to_recorded_location' + ) + ), + reminder_trigger_at TEXT NULL, + reminder_offset_minutes INTEGER NULL CHECK ( + reminder_offset_minutes IS NULL OR reminder_offset_minutes >= 0 + ), + reminder_strength TEXT NULL CHECK ( + reminder_strength IS NULL OR reminder_strength IN ('low', 'medium', 'high') + ), + reminder_disposition_state TEXT NULL CHECK ( + reminder_disposition_state IS NULL + OR reminder_disposition_state IN ('pending', 'confirmed', 'snoozed') + ), + next_trigger_at TEXT NULL, + snoozed_until TEXT NULL, + geofence_armed INTEGER NOT NULL DEFAULT 0 CHECK (geofence_armed IN (0, 1)), + disposition_updated_at TEXT NULL, + sync_status TEXT NOT NULL DEFAULT 'synced' CHECK (sync_status IN ('pending', 'synced')), + status TEXT NOT NULL CHECK (status IN ('active', 'deleted')), + cloud_revision INTEGER NOT NULL CHECK (cloud_revision > 0), + updated_at TEXT NOT NULL, + CHECK ( + (schedule_type = 'time' AND start_time IS NOT NULL) + OR ( + schedule_type = 'location' + AND start_time IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL + AND is_all_day = 0 + ) + ), + CHECK ( + (schedule_kind = 'once' AND recurrence_rule IS NULL) + OR ( + schedule_kind = 'recurring' + AND schedule_type = 'time' + AND recurrence_rule IS NOT NULL + ) + ), + CHECK ( + is_all_day = 0 + OR (schedule_type = 'time' AND start_time IS NOT NULL AND end_time IS NOT NULL) + ), + CHECK ( + (reminder_type IS NULL + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NULL + AND reminder_strength IS NULL) + OR (reminder_type IS NOT NULL AND reminder_strength IS NOT NULL) + ), + CHECK ( + reminder_type IS NULL + OR (reminder_type = 'at_time' + AND reminder_trigger_at IS NOT NULL + AND reminder_offset_minutes IS NULL) + OR (reminder_type = 'before_start' + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NOT NULL) + OR (reminder_type IN ('arrive_location', 'return_to_recorded_location') + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS ix_local_schedules_account_status_start_time + ON local_schedules (account_id, status, start_time); +CREATE INDEX IF NOT EXISTS ix_local_schedules_account_cloud_revision + ON local_schedules (account_id, cloud_revision); + +CREATE TABLE IF NOT EXISTS local_schedule_occurrence_overrides ( + id TEXT PRIMARY KEY NOT NULL, + schedule_id TEXT NOT NULL REFERENCES local_schedules(id) ON DELETE CASCADE, + occurrence_start TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('cancel', 'replace')), + replacement_schedule_id TEXT NULL REFERENCES local_schedules(id) ON DELETE RESTRICT, + UNIQUE (schedule_id, occurrence_start), + CHECK ( + (action = 'cancel' AND replacement_schedule_id IS NULL) + OR (action = 'replace' AND replacement_schedule_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS ix_local_schedule_overrides_replacement_schedule_id + ON local_schedule_occurrence_overrides (replacement_schedule_id); +`; + +export async function migrateScheduleDatabase(database: SQLiteDatabase): Promise { + await database.execAsync('PRAGMA foreign_keys = ON'); + const versionRow = await database.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + const currentVersion = versionRow?.user_version ?? 0; + + if (currentVersion > CURRENT_DATABASE_VERSION) { + throw new Error( + `Unsupported Timeflow database version ${currentVersion}; expected at most ${CURRENT_DATABASE_VERSION}`, + ); + } + if (currentVersion === CURRENT_DATABASE_VERSION) { + return; + } + + await database.withExclusiveTransactionAsync(async (transaction) => { + if (currentVersion < 1) { + await transaction.execAsync(CREATE_SCHEDULE_STORAGE_SQL); + await transaction.execAsync('PRAGMA user_version = 1'); + } + }); +} diff --git a/frontend/src/infrastructure/database/sqlite.ts b/frontend/src/infrastructure/database/sqlite.ts new file mode 100644 index 0000000..1ed4263 --- /dev/null +++ b/frontend/src/infrastructure/database/sqlite.ts @@ -0,0 +1,12 @@ +import { openDatabaseAsync, type SQLiteDatabase } from 'expo-sqlite'; + +import { migrateScheduleDatabase } from './migrations'; + +export const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; + +export async function openTimeflowDatabase(): Promise { + const database = await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + await database.execAsync('PRAGMA journal_mode = WAL'); + await migrateScheduleDatabase(database); + return database; +} diff --git a/frontend/tests/integration/scheduleLocalRepository.test.ts b/frontend/tests/integration/scheduleLocalRepository.test.ts new file mode 100644 index 0000000..11b40f0 --- /dev/null +++ b/frontend/tests/integration/scheduleLocalRepository.test.ts @@ -0,0 +1,302 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; +import initSqlJs, { type BindParams, type Database, type SqlJsStatic, type SqlValue } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { + ScheduleLocalRepository, + type CloudScheduleRow, + type LocalReminderRuntimeUpdate, + type LocalScheduleOccurrenceOverrideRow, +} from '../../src/features/schedule/data'; +import { + CURRENT_DATABASE_VERSION, + migrateScheduleDatabase, +} from '../../src/infrastructure/database/migrations'; + +class SqlJsExpoDatabase { + public constructor(private readonly database: Database) {} + + public async execAsync(sql: string): Promise { + this.database.exec(sql); + } + + public async runAsync(sql: string, ...parameters: unknown[]): Promise<{ changes: number }> { + this.database.run(sql, normalizeParameters(parameters)); + return { changes: this.database.getRowsModified() }; + } + + public async getFirstAsync(sql: string, ...parameters: unknown[]): Promise { + const statement = this.database.prepare(sql, normalizeParameters(parameters)); + try { + return statement.step() ? (statement.getAsObject() as Row) : null; + } finally { + statement.free(); + } + } + + public async getAllAsync(sql: string, ...parameters: unknown[]): Promise { + const statement = this.database.prepare(sql, normalizeParameters(parameters)); + const rows: Row[] = []; + try { + while (statement.step()) { + rows.push(statement.getAsObject() as Row); + } + return rows; + } finally { + statement.free(); + } + } + + public async withExclusiveTransactionAsync( + task: (transaction: SQLiteDatabase) => Promise, + ): Promise { + this.database.run('BEGIN EXCLUSIVE'); + try { + await task(this.asSQLiteDatabase()); + this.database.run('COMMIT'); + } catch (error) { + this.database.run('ROLLBACK'); + throw error; + } + } + + public asSQLiteDatabase(): SQLiteDatabase { + return this as unknown as SQLiteDatabase; + } + + public close(): void { + this.database.close(); + } +} + +function normalizeParameters(parameters: unknown[]): BindParams { + if (parameters.length === 0) { + return null; + } + const value = parameters.length === 1 ? parameters[0] : parameters; + if (Array.isArray(value)) { + return value as SqlValue[]; + } + if (typeof value === 'object' && value !== null) { + return value as Record; + } + return [value as SqlValue]; +} + +function cloudSchedule(overrides: Partial = {}): CloudScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'once', + title: 'Original title', + is_all_day: 0, + start_time: '2026-08-12T07:00:00Z', + end_time: null, + timezone: 'Asia/Shanghai', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + reminder_type: 'before_start', + reminder_trigger_at: null, + reminder_offset_minutes: 15, + reminder_strength: 'medium', + reminder_disposition_state: null, + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-11T07:00:00Z', + ...overrides, + }; +} + +function snoozedRuntime(): LocalReminderRuntimeUpdate { + return { + reminder_disposition_state: 'snoozed', + next_trigger_at: '2026-08-12T07:30:00Z', + snoozed_until: '2026-08-12T07:30:00Z', + geofence_armed: 1, + disposition_updated_at: '2026-08-12T07:20:00Z', + sync_status: 'pending', + }; +} + +describe('ScheduleLocalRepository SQLite behavior', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + }); + + afterEach(() => { + database.close(); + }); + + it('runs the migration and records the current schema version', async () => { + await migrateScheduleDatabase(database.asSQLiteDatabase()); + + const version = await database.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + const tables = await database.getAllAsync<{ name: string }>( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name LIKE 'local_%' + ORDER BY name`, + ); + + expect(version?.user_version).toBe(CURRENT_DATABASE_VERSION); + expect(tables.map((table) => table.name)).toEqual([ + 'local_schedule_occurrence_overrides', + 'local_schedules', + ]); + }); + + it('inserts a cloud schedule with safe local runtime defaults', async () => { + expect(await repository.applyCloudSchedule(cloudSchedule())).toBe(true); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored).toMatchObject({ + title: 'Original title', + cloud_revision: 1, + reminder_disposition_state: null, + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: null, + sync_status: 'synced', + status: 'active', + }); + }); + + it('applies cloud fields without overwriting device runtime state', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + await repository.updateReminderRuntime('account-a', 'schedule-a', snoozedRuntime()); + + expect( + await repository.applyCloudSchedule( + cloudSchedule({ + title: 'Cloud update', + reminder_disposition_state: 'confirmed', + cloud_revision: 2, + updated_at: '2026-08-11T08:00:00Z', + }), + ), + ).toBe(true); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored).toMatchObject({ + title: 'Cloud update', + cloud_revision: 2, + updated_at: '2026-08-11T08:00:00Z', + ...snoozedRuntime(), + }); + }); + + it('updates only local runtime fields and enforces account isolation', async () => { + const cloud = cloudSchedule(); + await repository.applyCloudSchedule(cloud); + + expect( + await repository.applyCloudSchedule( + cloudSchedule({ account_id: 'account-b', title: 'Cross-account cloud update' }), + ), + ).toBe(false); + expect(await repository.updateReminderRuntime('account-b', cloud.id, snoozedRuntime())).toBe( + false, + ); + expect(await repository.updateReminderRuntime('account-a', cloud.id, snoozedRuntime())).toBe( + true, + ); + + const stored = await repository.getSchedule('account-a', cloud.id); + expect(stored).toMatchObject({ + title: cloud.title, + start_time: cloud.start_time, + status: cloud.status, + cloud_revision: cloud.cloud_revision, + updated_at: cloud.updated_at, + ...snoozedRuntime(), + }); + }); + + it('stores cloud soft deletion and purges only through the explicit cleanup API', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + + expect( + await repository.applyCloudSchedule( + cloudSchedule({ + status: 'deleted', + cloud_revision: 2, + updated_at: '2026-08-11T08:00:00Z', + }), + ), + ).toBe(true); + expect(await repository.getSchedule('account-a', 'schedule-a')).toMatchObject({ + status: 'deleted', + cloud_revision: 2, + }); + + expect(await repository.purgeSchedule('account-b', 'schedule-a')).toBe(false); + expect(await repository.purgeSchedule('account-a', 'schedule-a')).toBe(true); + expect(await repository.getSchedule('account-a', 'schedule-a')).toBeNull(); + }); + + it('enforces override actions, uniqueness, ownership, and foreign keys', async () => { + await repository.applyCloudSchedule( + cloudSchedule({ id: 'series-a', schedule_kind: 'recurring', recurrence_rule: 'FREQ=DAILY' }), + ); + await repository.applyCloudSchedule(cloudSchedule({ id: 'replacement-a' })); + await repository.applyCloudSchedule( + cloudSchedule({ id: 'replacement-b', account_id: 'account-b' }), + ); + const cancel: LocalScheduleOccurrenceOverrideRow = { + id: 'override-a', + schedule_id: 'series-a', + occurrence_start: '2026-08-13T07:00:00Z', + action: 'cancel', + replacement_schedule_id: null, + }; + + expect(await repository.upsertOccurrenceOverride('account-a', cancel)).toBe(true); + expect( + await repository.upsertOccurrenceOverride('account-a', { + ...cancel, + action: 'replace', + replacement_schedule_id: 'replacement-a', + }), + ).toBe(true); + expect(await repository.listOccurrenceOverrides('account-a', 'series-a')).toEqual([ + { + ...cancel, + action: 'replace', + replacement_schedule_id: 'replacement-a', + }, + ]); + + await expect( + repository.upsertOccurrenceOverride('account-a', { + ...cancel, + id: 'override-duplicate', + }), + ).rejects.toThrow(); + expect( + await repository.upsertOccurrenceOverride('account-a', { + ...cancel, + id: 'override-cross-account', + action: 'replace', + replacement_schedule_id: 'replacement-b', + }), + ).toBe(false); + + await expect(repository.purgeSchedule('account-a', 'replacement-a')).rejects.toThrow(); + expect(await repository.purgeSchedule('account-a', 'series-a')).toBe(true); + expect(await repository.listOccurrenceOverrides('account-a', 'series-a')).toEqual([]); + expect(await repository.purgeSchedule('account-a', 'replacement-a')).toBe(true); + }); +}); diff --git a/frontend/tests/scheduleStorageContracts.test-d.ts b/frontend/tests/scheduleStorageContracts.test-d.ts new file mode 100644 index 0000000..d4f8d04 --- /dev/null +++ b/frontend/tests/scheduleStorageContracts.test-d.ts @@ -0,0 +1,115 @@ +import type { ReminderDispositionState as CloudReminderDispositionState } from '../src/contracts/schedule'; +import type { + CloudScheduleRow, + LocalReminderDispositionState, + LocalReminderRuntimeUpdate, + LocalScheduleOccurrenceOverrideRow, + LocalScheduleRow, + ScheduleLocalRepository, +} from '../src/features/schedule/data'; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; + +type Assert = Condition; + +export type LocalReminderStateContract = Assert< + Equal +>; + +export type LocalSnoozeDoesNotEnterCloudContract = Assert< + Equal, never> +>; + +export type LocalScheduleStorageColumnsContract = Assert< + Equal< + keyof LocalScheduleRow, + | 'id' + | 'account_id' + | 'schedule_type' + | 'schedule_kind' + | 'title' + | 'is_all_day' + | 'start_time' + | 'end_time' + | 'timezone' + | 'recurrence_rule' + | 'location_name' + | 'latitude' + | 'longitude' + | 'reminder_type' + | 'reminder_trigger_at' + | 'reminder_offset_minutes' + | 'reminder_strength' + | 'reminder_disposition_state' + | 'next_trigger_at' + | 'snoozed_until' + | 'geofence_armed' + | 'disposition_updated_at' + | 'sync_status' + | 'status' + | 'cloud_revision' + | 'updated_at' + > +>; + +export type CloudScheduleStorageColumnsContract = Assert< + Equal< + keyof CloudScheduleRow, + | 'id' + | 'account_id' + | 'schedule_type' + | 'schedule_kind' + | 'title' + | 'is_all_day' + | 'start_time' + | 'end_time' + | 'timezone' + | 'recurrence_rule' + | 'location_name' + | 'latitude' + | 'longitude' + | 'reminder_type' + | 'reminder_trigger_at' + | 'reminder_offset_minutes' + | 'reminder_strength' + | 'reminder_disposition_state' + | 'status' + | 'cloud_revision' + | 'updated_at' + > +>; + +export type LocalReminderRuntimeColumnsContract = Assert< + Equal< + keyof LocalReminderRuntimeUpdate, + | 'reminder_disposition_state' + | 'next_trigger_at' + | 'snoozed_until' + | 'geofence_armed' + | 'disposition_updated_at' + | 'sync_status' + > +>; + +export type LocalOccurrenceOverrideStorageColumnsContract = Assert< + Equal< + keyof LocalScheduleOccurrenceOverrideRow, + 'id' | 'schedule_id' | 'occurrence_start' | 'action' | 'replacement_schedule_id' + > +>; + +export type LocalRepositoryOperationsContract = Assert< + Equal< + keyof ScheduleLocalRepository, + | 'getSchedule' + | 'listSchedules' + | 'applyCloudSchedule' + | 'updateReminderRuntime' + | 'purgeSchedule' + | 'upsertOccurrenceOverride' + | 'listOccurrenceOverrides' + > +>;