├── apps
├── mobile
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── assets
│ │ ├── icon.png
│ │ ├── favicon.png
│ │ ├── splash.png
│ │ └── adaptive-icon.png
│ ├── app
│ │ ├── store.ts
│ │ ├── db
│ │ │ └── init.ts
│ │ ├── todo.tsx
│ │ ├── sync
│ │ │ └── SyncedExpoDB.ts
│ │ └── utils.ts
│ ├── babel.config.js
│ ├── index.js
│ ├── app.json
│ ├── metro.config.js
│ ├── package.json
│ └── App.tsx
└── server
│ ├── .gitignore
│ ├── schemas
│ └── test.sql
│ ├── package.json
│ └── index.js
├── .eslintrc
├── .yarnrc.yml
├── package.json
├── .gitignore
└── README.md
/apps/mobile/.gitignore:
--------------------------------------------------------------------------------
1 | ios/
2 | android/
--------------------------------------------------------------------------------
/apps/server/.gitignore:
--------------------------------------------------------------------------------
1 | .partykit
2 | dbs/
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["universe/native"]
3 | }
4 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
3 | yarnPath: .yarn/releases/yarn-3.6.1.cjs
4 |
--------------------------------------------------------------------------------
/apps/mobile/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {},
3 | "extends": "expo/tsconfig.base"
4 | }
5 |
--------------------------------------------------------------------------------
/apps/mobile/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/icon.png
--------------------------------------------------------------------------------
/apps/mobile/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/favicon.png
--------------------------------------------------------------------------------
/apps/mobile/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/splash.png
--------------------------------------------------------------------------------
/apps/mobile/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/apps/server/schemas/test.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "todo" ("id" PRIMARY KEY, "text", "completed" INTEGER DEFAULT 0);
2 | SELECT crsql_as_crr('todo');
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expo-sqlite-cr-sqlite-monorepo-demo",
3 | "version": "1.0.0",
4 | "private": true,
5 | "workspaces": [
6 | "apps/*"
7 | ],
8 | "packageManager": "yarn@3.6.1"
9 | }
10 |
--------------------------------------------------------------------------------
/apps/mobile/app/store.ts:
--------------------------------------------------------------------------------
1 | import { createStore } from 'tinybase';
2 |
3 | export const store = createStore().setTablesSchema({
4 | todo: {
5 | id: { type: "number" },
6 | text: { type: "string" },
7 | completed: { type: "number", default: 0 },
8 | },
9 | });
10 |
--------------------------------------------------------------------------------
/apps/mobile/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ["babel-preset-expo"],
5 | plugins: [
6 | ["@babel/plugin-transform-private-methods", { loose: true }],
7 | "react-native-reanimated/plugin",
8 | ],
9 | };
10 | };
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 | .yarn/
13 |
14 | # macOS
15 | .DS_Store
16 |
17 | # Temporary files created by Metro to check the health of the file watcher
18 | .metro-health-check*
19 |
--------------------------------------------------------------------------------
/apps/mobile/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from 'expo';
2 | import App from './App';
3 |
4 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
5 | // It also ensures that whether you load the app in Expo Go or in a native build,
6 | // the environment is set up appropriately
7 | registerRootComponent(App);
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Proof of concept: expo-sqlite integration with CR-SQLite
2 |
3 | For a detailed look at what this repository is, why it exists, how it works, and how to run it, see this blog post: https://expo.dev/changelog/2023/08-10-cr-sqlite. Refer to the [initial-poc branch](https://github.com/expo/todo-sync-example/tree/initial-poc)on) for the code that was used in the blog post.
--------------------------------------------------------------------------------
/apps/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "start": "node ./index.js",
9 | "test": "echo \"Error: no test specified\" && exit 1"
10 | },
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "@vlcn.io/ws-server": "0.1.0-next.14",
15 | "express": "^4.18.2"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/apps/mobile/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "todo-sync-mobile",
4 | "slug": "todo-sync-mobile",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "userInterfaceStyle": "light",
8 | "ios": {
9 | "supportsTablet": true,
10 | "bundleIdentifier": "com.example.todosync"
11 | },
12 | "android": {
13 | "adaptiveIcon": {
14 | "foregroundImage": "./assets/adaptive-icon.png",
15 | "backgroundColor": "#ffffff"
16 | },
17 | "package": "com.example.todosync"
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/apps/server/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import * as http from "http";
4 | import { attachWebsocketServer } from "@vlcn.io/ws-server";
5 | import express from "express";
6 | import { existsSync, mkdirSync } from 'fs';
7 |
8 | const dbFolder = './dbs';
9 | const port = process.env.PORT || 8080;
10 |
11 | const app = express();
12 | const server = http.createServer(app);
13 |
14 | if (!existsSync(dbFolder)) {
15 | mkdirSync(dbFolder);
16 | }
17 |
18 | attachWebsocketServer(server, {
19 | schemaFolder: "./schemas",
20 | dbFolder,
21 | pathPattern: /\/sync/,
22 | });
23 |
24 | server.listen(port, () => console.log("info", `listening on port ${port}!`));
25 |
--------------------------------------------------------------------------------
/apps/mobile/metro.config.js:
--------------------------------------------------------------------------------
1 | const { getDefaultConfig } = require('expo/metro-config');
2 | const path = require('path');
3 |
4 | // Find the project and workspace directories
5 | const projectRoot = __dirname;
6 | // This can be replaced with `find-yarn-workspace-root`
7 | const workspaceRoot = path.resolve(projectRoot, '../..');
8 |
9 | const config = getDefaultConfig(projectRoot);
10 |
11 | // 1. Watch all files within the monorepo
12 | config.watchFolders = [workspaceRoot];
13 | // 2. Let Metro know where to resolve packages and in what order
14 | config.resolver.nodeModulesPaths = [
15 | path.resolve(projectRoot, 'node_modules'),
16 | path.resolve(workspaceRoot, 'node_modules'),
17 | ];
18 | // 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
19 | config.resolver.disableHierarchicalLookup = true;
20 |
21 | module.exports = config;
22 |
--------------------------------------------------------------------------------
/apps/mobile/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mobile",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "start": "expo start",
6 | "android": "expo run:android",
7 | "ios": "expo run:ios"
8 | },
9 | "dependencies": {
10 | "@babel/plugin-transform-private-methods": "^7.22.5",
11 | "@types/react": "~18.2.14",
12 | "@vlcn.io/ws-client": "0.1.0-next.5",
13 | "@vlcn.io/ws-common": "0.1.0-next.0",
14 | "base-64": "^1.0.0",
15 | "expo": "^49.0.4",
16 | "expo-crypto": "~12.4.1",
17 | "expo-splash-screen": "~0.20.4",
18 | "expo-sqlite": "~11.7.1",
19 | "expo-status-bar": "~1.6.0",
20 | "react": "18.2.0",
21 | "react-native": "0.72.3",
22 | "react-native-reanimated": "~3.3.0",
23 | "tinybase": "^4.1.0",
24 | "typescript": "^5.1.3"
25 | },
26 | "devDependencies": {
27 | "@babel/core": "^7.20.0"
28 | },
29 | "private": true
30 | }
31 |
--------------------------------------------------------------------------------
/apps/mobile/app/db/init.ts:
--------------------------------------------------------------------------------
1 | import * as SQLite from "expo-sqlite";
2 | import { createSingletonDbProvider } from "../sync/SyncedExpoDB";
3 | import { cryb64 } from "@vlcn.io/ws-common";
4 | import {decode, encode} from 'base-64'
5 | if (!global.btoa) {
6 | global.btoa = encode;
7 | }
8 |
9 | if (!global.atob) {
10 | global.atob = decode;
11 | }
12 |
13 | export const dbName = "test.db";
14 | export const db = SQLite.openDatabase(dbName);
15 |
16 | // TODO: ideally we can share the schema in a package between client and server
17 | // this is curently duplicated into /server/schemas/test.sql
18 | const schema = [
19 | `CREATE TABLE IF NOT EXISTS "todo" ("id" PRIMARY KEY, "text", "completed" INTEGER DEFAULT 0);`,
20 | `SELECT crsql_as_crr('todo');`
21 | ];
22 |
23 | export function initDatabase() {
24 | db.exec(
25 | schema.map((sql) => ({ sql, args: [] })),
26 | false,
27 | () => {}
28 | );
29 | }
30 |
31 | export const dbProvider = createSingletonDbProvider({
32 | dbName,
33 | db,
34 | // TODO: users shouldn't manually deal with any of this.
35 | // The browser db wrappers of cr-sqlite support automigration
36 | // but we don't have that in the Expo bindings yet.
37 | schemaName: "test.sql",
38 | schemaVersion: cryb64(schema.join("\n")),
39 | });
40 |
--------------------------------------------------------------------------------
/apps/mobile/app/todo.tsx:
--------------------------------------------------------------------------------
1 | import { EvilIcons, AntDesign, MaterialIcons } from "@expo/vector-icons";
2 | import { Text, Pressable, StyleSheet } from "react-native";
3 | import Animated, { BounceIn, SlideInRight } from "react-native-reanimated";
4 | import {
5 | RowProps,
6 | useCell,
7 | useDelRowCallback,
8 | useSetCellCallback,
9 | } from "tinybase/lib/ui-react";
10 |
11 | const AnimatedCheckmark = Animated.createAnimatedComponent(AntDesign);
12 | const ICON_SIZE = 24;
13 |
14 | export type Todo = {
15 | id: number;
16 | text: string;
17 | completed: boolean;
18 | };
19 |
20 | export function TodoRow({ tableId, rowId }: RowProps) {
21 | const todo = useCell(tableId, rowId, "text");
22 | const completed = useCell(tableId, rowId, "completed");
23 |
24 | const toggleTodo = useSetCellCallback(
25 | tableId,
26 | rowId,
27 | "completed",
28 | () => (completed === 0 ? 1 : 0),
29 | [completed]
30 | );
31 |
32 | const deleteRow = useDelRowCallback(tableId, rowId);
33 |
34 | return (
35 |
36 | [
38 | styles.container,
39 | {
40 | borderColor: completed ? "#10cc1f" : "rgba(0,0,0,.5)",
41 | opacity: pressed ? 0.5 : 1,
42 | },
43 | ]}
44 | onPress={toggleTodo}
45 | >
46 | {completed ? (
47 |
53 | ) : (
54 |
59 | )}
60 | {todo}
61 |
62 |
63 |
64 |
65 | );
66 | }
67 |
68 | const styles = StyleSheet.create({
69 | container: {
70 | width: "100%",
71 | borderRadius: 10,
72 | marginVertical: 5,
73 | padding: 10,
74 | flexDirection: "row",
75 | alignItems: "center",
76 | justifyContent: "space-between",
77 | borderWidth: 2,
78 | backgroundColor: "white",
79 | gap: 10,
80 | },
81 | title: {
82 | textAlign: "left",
83 | fontSize: 20,
84 | fontWeight: "bold",
85 | flex: 1,
86 | },
87 | status: {
88 | flexDirection: "row",
89 | alignItems: "center",
90 | gap: 10,
91 | },
92 | });
93 |
--------------------------------------------------------------------------------
/apps/mobile/App.tsx:
--------------------------------------------------------------------------------
1 | import { initDatabase, db, dbProvider, dbName } from "./app/db/init";
2 | import { StatusBar } from "expo-status-bar";
3 | import {
4 | StyleSheet,
5 | Text,
6 | View,
7 | ScrollView,
8 | Image,
9 | TouchableOpacity,
10 | Platform,
11 | } from "react-native";
12 | import { TodoRow } from "./app/todo";
13 | import {
14 | Provider,
15 | SortedTableView,
16 | useCreatePersister,
17 | useDelTableCallback,
18 | useStore,
19 | } from "tinybase/lib/ui-react";
20 | import { createExpoSqlitePersister } from "tinybase/lib/persisters/persister-expo-sqlite";
21 | import { generateRandomTodo, nanoid } from "./app/utils";
22 | import { useCallback, useEffect } from "react";
23 | import { createSyncedDB, defaultConfig } from "@vlcn.io/ws-client";
24 | import { store } from "./app/store";
25 |
26 | const uri =
27 | "https://images.unsplash.com/photo-1631891318333-dc891d26f52a?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjZ8fGxhbmRtYXJrcyUyMHdhbGxwYXBlcnxlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=500&q=60";
28 |
29 | export default function App() {
30 | return (
31 |
32 |
33 |
34 | );
35 | }
36 |
37 | const host = Platform.OS === "ios" ? "localhost" : "10.0.2.2";
38 |
39 | function TodoList() {
40 | const store = useStore();
41 |
42 | // TODO: allow enabling/disabling sync
43 | // TODO: return cleanup
44 | useEffect(() => {
45 | initDatabase();
46 | const syncedDbPromise = createSyncedDB(
47 | {
48 | dbProvider: dbProvider,
49 | transportProvider: defaultConfig.transportProvider,
50 | },
51 | dbName,
52 | {
53 | room: "my-room",
54 | url: `ws://${host}:8080/sync`,
55 | }
56 | ).then((synced) => {
57 | synced.start();
58 | return synced;
59 | });
60 |
61 | return () => {
62 | syncedDbPromise.then((synced) => synced.stop());
63 | };
64 | }, []);
65 |
66 | useCreatePersister(
67 | store,
68 | (store) =>
69 | createExpoSqlitePersister(
70 | store,
71 | db,
72 | {
73 | mode: "tabular",
74 | tables: {
75 | load: { todo: { tableId: "todo", rowIdColumnName: "id" } },
76 | save: { todo: { tableName: "todo", rowIdColumnName: "id" } },
77 | },
78 | },
79 | console.info
80 | ),
81 | [db],
82 | async (persister) => {
83 | await persister.startAutoLoad();
84 | await persister.startAutoSave();
85 | }
86 | );
87 |
88 | const addTodo = useCallback(
89 | () => store.setCell("todo", nanoid(10), "text", generateRandomTodo()),
90 | [store]
91 | );
92 |
93 | const deleteTodo = useDelTableCallback("todo", store);
94 |
95 | return (
96 |
97 |
98 |
99 |
104 |
105 |
106 |
110 | Add Todo
111 |
112 |
113 |
117 | Delete All Todos
118 |
119 |
120 |
121 |
125 |
131 |
132 |
133 |
134 | );
135 | }
136 |
137 | const styles = StyleSheet.create({
138 | container: {
139 | flex: 1,
140 | alignItems: "center",
141 | gap: 10,
142 | },
143 | btn: { backgroundColor: "white", padding: 10, borderRadius: 5 },
144 | btnText: { fontSize: 18, textAlign: "center" },
145 | });
146 |
--------------------------------------------------------------------------------
/apps/mobile/app/sync/SyncedExpoDB.ts:
--------------------------------------------------------------------------------
1 | import { DB } from "@vlcn.io/ws-client";
2 | import { Change, bytesToHex, hexToBytes } from "@vlcn.io/ws-common";
3 | import { SQLiteDatabase } from "expo-sqlite";
4 |
5 | class SyncedExpoDB implements DB {
6 | #db: SQLiteDatabase;
7 | #siteId: Uint8Array;
8 | #schemaName: string;
9 | #schemaVersion: bigint;
10 |
11 | constructor(
12 | db: SQLiteDatabase,
13 | siteId: Uint8Array,
14 | schemaName: string,
15 | schemaVersion: bigint
16 | ) {
17 | this.#db = db;
18 | this.#siteId = siteId;
19 | this.#schemaName = schemaName;
20 | this.#schemaVersion = schemaVersion;
21 | }
22 |
23 | get siteid() {
24 | return this.#siteId;
25 | }
26 |
27 | onChange(cb: () => void): () => void {
28 | console.log("registering db listener...");
29 | const subscription = this.#db.onDatabaseChange(cb);
30 | return () => {
31 | subscription.remove();
32 | };
33 | }
34 |
35 | getSchemaNameAndVersion(): PromiseLike<[string, bigint]> {
36 | return Promise.resolve([this.#schemaName, this.#schemaVersion]);
37 | }
38 |
39 | async pullChangeset(
40 | since: readonly [bigint, number],
41 | excludeSites: readonly Uint8Array[],
42 | localOnly: boolean
43 | ): Promise {
44 | console.log("pulling changes");
45 | const resultSet = await this.#db.execAsync(
46 | [
47 | {
48 | // Have to do a hex conversion since expo-sqlite doesn't support blobs
49 | // 0 as "cl" is a placeholder given cl does not exist in 0.14.0
50 |
51 | sql: `SELECT "table", hex("pk") as "pk", "cid", "val", "col_version", "db_version", NULL, "cl" FROM crsql_changes WHERE db_version > ? AND site_id IS NOT unhex(?)`,
52 | args: [Number(since[0]), bytesToHex(excludeSites[0])],
53 | },
54 | ],
55 | true
56 | );
57 | const ret = resultSet[0];
58 | if ("error" in ret) {
59 | throw ret.error;
60 | }
61 | console.log(`Pulled ${ret.rows.length} changes since ${since[0]}`);
62 | return ret.rows.map((row) => {
63 | const { table, pk, cid, val, col_version, db_version, cl } = row;
64 | return [
65 | table,
66 | // and then back to a bytes again :/
67 | hexToBytes(pk),
68 | cid,
69 | val,
70 | BigInt(col_version),
71 | BigInt(db_version),
72 | null,
73 | BigInt(cl),
74 | ];
75 | });
76 | }
77 |
78 | async applyChangesetAndSetLastSeen(
79 | changes: readonly Change[],
80 | siteId: Uint8Array,
81 | end: readonly [bigint, number]
82 | ): Promise {
83 | console.log("applying changes");
84 | await this.#db.transactionAsync(async (transaction) => {
85 | const sql = `INSERT INTO crsql_changes ("table", "pk", "cid", "val", "col_version", "db_version", "site_id", "cl") VALUES (?, unhex(?), ?, ?, ?, ?, unhex(?), ?)`;
86 | for (const change of changes) {
87 | const [table, pk, cid, val, col_version, db_version, _, cl] = change;
88 | // TODO: expo blob bindings may still not work.. in which case we need to finagle with the bindings and `pk` to get it to work
89 | // doing these inserts in parallel wouldn't make sense hence awaiting in a loop.
90 | // also col_version, db_version may need to be coerced to numbers from bigints...
91 | // oof... the lack of bigin 😬
92 | const bind = [
93 | table,
94 | bytesToHex(pk),
95 | cid,
96 | typeof val === "bigint" ? Number(val) : val,
97 | Number(col_version),
98 | Number(db_version),
99 | bytesToHex(siteId),
100 | Number(cl),
101 | ];
102 | console.log(bind);
103 | await transaction.executeSqlAsync(sql, bind);
104 | console.log(bind);
105 | }
106 | await transaction.executeSqlAsync(
107 | `INSERT INTO "crsql_tracked_peers" ("site_id", "event", "version", "seq", "tag") VALUES (unhex(?), ?, ?, ?, 0) ON CONFLICT DO UPDATE SET
108 | "version" = MAX("version", excluded."version"),
109 | "seq" = CASE "version" > excluded."version" WHEN 1 THEN "seq" ELSE excluded."seq" END`,
110 | // TODO: expo doesn't support bigints.
111 | // This is okish since we'll never actually hit 2^53
112 | // TODO: hexify siteId? and unhex it in the db?
113 | [bytesToHex(siteId), 0, Number(end[0]), end[1]]
114 | );
115 | });
116 | }
117 |
118 | async getLastSeens(): Promise<[Uint8Array, [bigint, number]][]> {
119 | console.log("getting last seens");
120 | // TODO: more hexing and unhexing due to lack of blob support
121 | // in the expo bindings
122 | const resultSet = await this.#db.execAsync(
123 | [
124 | {
125 | sql: `SELECT hex("site_id") as "site_id", "version", "seq" FROM crsql_tracked_peers`,
126 | args: [],
127 | },
128 | ],
129 | true
130 | );
131 | const ret = resultSet[0];
132 | if ("error" in ret) {
133 | throw ret.error;
134 | }
135 | return ret.rows.map((row) => {
136 | const { site_id, version, seq } = row;
137 | return [hexToBytes(site_id), [BigInt(version), seq]];
138 | });
139 | }
140 |
141 | close(closeWrappedDB: false) {
142 | if (closeWrappedDB) {
143 | this.#db.closeSync();
144 | }
145 | }
146 | }
147 |
148 | export type SingletonDescriptor = {
149 | dbName: string;
150 | db: SQLiteDatabase;
151 | schemaName: string;
152 | schemaVersion: bigint;
153 | };
154 | // This is a basic provider just for demonstration purposes.
155 | // It expect callers to use an already open an existing database.
156 | export function createSingletonDbProvider({
157 | dbName: requiredDbName,
158 | db,
159 | schemaName,
160 | schemaVersion,
161 | }: SingletonDescriptor): (dbName: string) => Promise {
162 | return async (dbName: string) => {
163 | if (dbName !== requiredDbName) {
164 | throw new Error(`The singleton provider only supports ${requiredDbName}`);
165 | }
166 | const resultSet = await db.execAsync(
167 | [
168 | {
169 | sql: `SELECT hex(crsql_site_id()) as site_id`,
170 | args: [],
171 | },
172 | ],
173 | true
174 | );
175 | const ret = resultSet[0];
176 | if ("error" in ret) {
177 | throw ret.error;
178 | }
179 | const siteId = hexToBytes(ret.rows[0]["site_id"]);
180 | return new SyncedExpoDB(db, siteId, schemaName, schemaVersion);
181 | };
182 | }
183 |
--------------------------------------------------------------------------------
/apps/mobile/app/utils.ts:
--------------------------------------------------------------------------------
1 | import * as crypto from "expo-crypto";
2 |
3 | const todoList = [
4 | "Buy groceries",
5 | "Pay bills",
6 | "Clean the house",
7 | "Exercise",
8 | "Finish the report",
9 | "Call mom",
10 | "Read a book",
11 | "Walk the dog",
12 | "Go for a run",
13 | "Water the plants",
14 | "Cook dinner",
15 | "Do the laundry",
16 | "Study for exam",
17 | "Write a blog post",
18 | "Practice coding",
19 | "Schedule appointment",
20 | "Volunteer at a shelter",
21 | "Organize desk",
22 | "Call a friend",
23 | "Plan a trip",
24 | "Watch a movie",
25 | "Take a nap",
26 | "Learn a new skill",
27 | "Attend a workshop",
28 | "Buy a birthday gift",
29 | "Send a thank-you note",
30 | "Clean out the fridge",
31 | "Start a journal",
32 | "Meditate for 10 minutes",
33 | "Fix a broken item",
34 | "Try a new recipe",
35 | "Go stargazing",
36 | "Sort out old clothes",
37 | "Create a budget",
38 | "Donate to charity",
39 | "Read the news",
40 | "Take a nature walk",
41 | "Learn to play an instrument",
42 | "Visit a museum",
43 | "Write a poem",
44 | "Do a random act of kindness",
45 | "Explore a new neighborhood",
46 | "Call a family member",
47 | "Plan a picnic",
48 | "Build a DIY project",
49 | "Join a book club",
50 | "Learn a magic trick",
51 | "Write a letter to yourself",
52 | "Research a new hobby",
53 | "Paint a picture",
54 | "Start a compost bin",
55 | "Try a new restaurant",
56 | "Do a puzzle",
57 | "Listen to a podcast",
58 | "Plant flowers in the garden",
59 | "Play a board game",
60 | "Take a cooking class",
61 | "Practice mindfulness",
62 | "Create a vision board",
63 | "Learn a new language",
64 | "Go to a farmer's market",
65 | "Declutter a room",
66 | "Visit a local landmark",
67 | "Do a home workout",
68 | "Attend a live performance",
69 | "Explore a nearby park",
70 | "Try a new hairstyle",
71 | "Play with a pet",
72 | "Write a short story",
73 | "Try a new type of cuisine",
74 | "Do a science experiment",
75 | "Watch a documentary",
76 | "Take a photography walk",
77 | "Do a crossword puzzle",
78 | "Learn to dance",
79 | "Write a to-do list for tomorrow",
80 | "Make a handmade gift",
81 | "Watch the sunrise",
82 | "Write down your dreams",
83 | "Have a technology-free day",
84 | "Take a bike ride",
85 | "Create a playlist of favorite songs",
86 | "Learn about a historical event",
87 | "Play a video game",
88 | "Try a new type of exercise",
89 | "Do a DIY home improvement",
90 | "Visit a local library",
91 | "Write a gratitude journal",
92 | "Join a community event",
93 | "Make a list of places to visit",
94 | "Try a new type of tea",
95 | "Do a brain-teaser puzzle",
96 | "Watch the sunset",
97 | "Create a scrapbook",
98 | "Go on a road trip",
99 | "Learn to juggle",
100 | "Explore a nearby forest",
101 | "Try a new board game",
102 | "Take a dance class",
103 | "Visit an art gallery",
104 | "Write a list of things you love about yourself",
105 | "Attend a meditation session",
106 | "Have a picnic in the park",
107 | "Try a new type of art",
108 | "Do a virtual escape room",
109 | "Learn to do origami",
110 | "Watch a stand-up comedy show",
111 | "Have a spa day at home",
112 | "Learn to solve a Rubik's cube",
113 | "Do a DIY fashion project",
114 | "Visit a botanical garden",
115 | "Write a letter to your future self",
116 | "Make a list of favorite quotes",
117 | "Try a new type of dessert",
118 | "Do a word search puzzle",
119 | "Explore a local market",
120 | "Try a new type of meditation",
121 | "Watch a classic movie",
122 | "Take a pottery class",
123 | "Learn to do magic tricks",
124 | "Visit a wildlife sanctuary",
125 | "Write a list of personal goals",
126 | "Have a movie marathon",
127 | "Try a new outdoor activity",
128 | "Do a DIY art project",
129 | "Explore a nearby lake",
130 | "Take a singing lesson",
131 | "Visit a historical site",
132 | "Write a short gratitude letter to someone",
133 | "Try a new type of cuisine",
134 | "Do a nature scavenger hunt",
135 | "Watch a wildlife documentary",
136 | "Have a game night with friends",
137 | "Learn to play chess",
138 | "Visit a science museum",
139 | "Write a letter to a future grandchild",
140 | "Make a list of places you want to travel to",
141 | "Try a new form of exercise",
142 | "Do a DIY woodworking project",
143 | "Explore a nearby beach",
144 | "Take a cooking class",
145 | "Learn to do calligraphy",
146 | "Visit a planetarium",
147 | "Write a letter to your past self",
148 | "Try a new type of craft",
149 | "Do a virtual museum tour",
150 | "Watch a documentary on space exploration",
151 | "Have a karaoke night",
152 | "Learn to do card tricks",
153 | "Visit a local zoo",
154 | "Write a list of things you're grateful for",
155 | "Try a new type of music",
156 | "Do a virtual art class",
157 | "Explore a nearby mountain",
158 | "Take a photography class",
159 | "Visit a local farm",
160 | "Write a letter to a mentor or role model",
161 | "Try a new form of dance",
162 | "Do a DIY gardening project",
163 | "Watch a historical documentary",
164 | "Have a board game marathon",
165 | "Learn to play a musical instrument",
166 | "Visit a natural hot spring",
167 | "Write a letter to your favorite author",
168 | "Try a new type of exercise",
169 | "Do a virtual cooking class",
170 | "Explore a nearby city",
171 | "Take a painting class",
172 | "Learn to do pottery",
173 | "Visit a local art fair",
174 | "Write a list of inspiring people",
175 | "Try a new form of meditation",
176 | "Do a DIY home decor project",
177 | "Watch a wildlife livestream",
178 | "Have a themed costume party",
179 | "Learn to do balloon animals",
180 | "Visit a local theater performance",
181 | "Write a letter to your future self",
182 | "Try a new type of cuisine",
183 | "Do a virtual yoga class",
184 | "Explore a nearby nature reserve",
185 | "Take a woodworking class",
186 | "Visit a historical reenactment",
187 | "Write a letter to your future child",
188 | "Try a new type of craft",
189 | "Do a virtual fitness class",
190 | "Watch a documentary on marine life",
191 | "Have a movie night under the stars",
192 | "Learn to do flower arrangements",
193 | "Visit a botanical garden",
194 | "Write a list of your favorite books",
195 | "Try a new form of art",
196 | "Do a virtual escape room",
197 | "Explore a nearby hiking trail",
198 | "Take a dancing class",
199 | "Visit an art gallery",
200 | "Write a list of personal affirmations",
201 | ];
202 |
203 | export function generateRandomTodo() {
204 | return todoList[randInt(todoList.length)];
205 | }
206 |
207 | function randInt(lessThan: number) {
208 | return Math.floor(Math.random() * lessThan);
209 | }
210 |
211 | export function hexToBytes(hex: string) {
212 | const ret = new Uint8Array(hex.length / 2);
213 | for (let c = 0; c < hex.length; c += 2) {
214 | ret[c / 2] = parseInt(hex.substring(c, c + 2), 16);
215 | }
216 | return ret;
217 | }
218 |
219 | export function bytesToHex(bytes: Uint8Array) {
220 | let hex: string[] = [];
221 | for (let i = 0; i < bytes.length; i++) {
222 | let current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i];
223 | hex.push((current >>> 4).toString(16));
224 | hex.push((current & 0xf).toString(16));
225 | }
226 | return hex.join("");
227 | }
228 |
229 | export function nanoid(t = 21) {
230 | return crypto
231 | .getRandomValues(new Uint8Array(t))
232 | .reduce(
233 | (t, e) =>
234 | (t +=
235 | (e &= 63) < 36
236 | ? e.toString(36)
237 | : e < 62
238 | ? (e - 26).toString(36).toUpperCase()
239 | : e > 62
240 | ? "-"
241 | : "_"),
242 | ""
243 | );
244 | }
245 |
--------------------------------------------------------------------------------