├── .gitignore ├── README.md ├── drizzle.config.ts ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── apple-touch-icon-180x180.png ├── favicon.ico ├── favicon.svg ├── maskable-icon-512x512.png ├── pwa-192x192.png ├── pwa-512x512.png └── pwa-64x64.png ├── src ├── components │ ├── CreateFood.tsx │ ├── CreatePlan.tsx │ ├── CreateServing.tsx │ ├── DailyPlanCard.tsx │ ├── FoodEditing.tsx │ ├── ManageServing.tsx │ ├── PlanCard.tsx │ ├── PlanInfo.tsx │ ├── QuantityField.tsx │ ├── SelectFood.tsx │ ├── ServingCard.tsx │ ├── UpdateDailyPlan.tsx │ ├── UpdateFood.tsx │ └── ui │ │ ├── Button.tsx │ │ ├── Dialog.tsx │ │ ├── Icons.tsx │ │ ├── Modal.tsx │ │ ├── NumberField.tsx │ │ ├── Spinner.tsx │ │ └── TextField.tsx ├── drizzle │ ├── 0000_thin_wilson_fisk.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ └── _journal.json ├── hooks │ ├── use-daily-log.ts │ ├── use-daily-plan.ts │ ├── use-foods.ts │ ├── use-pglite-drizzle.ts │ ├── use-plans.ts │ └── use-query.ts ├── machines │ ├── create-plan.ts │ ├── create-serving.ts │ ├── manage-daily-log.ts │ ├── manage-food.ts │ ├── manage-plan.ts │ ├── manage-serving.ts │ ├── number-field.ts │ ├── optional-number-field.ts │ └── text-field.ts ├── main.tsx ├── routeTree.gen.ts ├── routes │ ├── __root.tsx │ ├── index.tsx │ └── plan │ │ └── index.tsx ├── schema │ ├── daily-log.ts │ ├── drizzle.ts │ ├── food.ts │ ├── plan.ts │ ├── serving.ts │ └── shared.ts ├── services │ ├── migrations.ts │ ├── pglite.ts │ └── runtime-client.ts ├── tailwind.css └── utils.ts ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Local 2 | .DS_Store 3 | *.local 4 | *.log* 5 | 6 | # Dist 7 | node_modules 8 | dist/ 9 | .vinxi 10 | .output 11 | .vercel 12 | .netlify 13 | .wrangler 14 | 15 | # IDE 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Local-only calories tracker app 2 | 3 | > You can read a full overview of the project: [Local-only calories tracker app](https://www.typeonce.dev/course/calories-tracker-local-only-app) 4 | 5 | A new paradigm of building apps is coming to the web: **Local-first**. 6 | 7 | One of the key components of a local-first app is having a **local data store**. [PGlite](https://pglite.dev/) brings the database to the client, allowing you to build apps completely offline. 8 | 9 | In this project template we implement the first step before local-first: **Local-only**. 10 | 11 | The project is a Vite app completely on the client. It uses PGlite as local `postgres` database to make the app work end-to-end on the user device. 12 | 13 | ## Tech stack 14 | The project is built with **TanStack Router**, which offers complete type-safe routing on the client for React. 15 | 16 | A local `postgres` database is created and managed using PGlite in combination with `drizzle` as ORM. The app includes the `live` extension of PGlite, which allow writing **reactive queries as normal React hooks**. 17 | 18 | > `drizzle` is also responsible to manage migrations for local databases using `drizzle-kit` 19 | 20 | The state of the app is managed using `xstate`. Each functionality is defined in a separate machine. The app makes heavy use of the **actor model** to combine reusable actors. 21 | 22 | The structure and logic of the app is implemented using `effect`. All the logic is contained in isolated services that are combined inside layers and executed as part of TanStack Router `loaders` and `xstate` actors. 23 | 24 | All the validation and serialization logic is also implemented using `Schema` from `effect`. 25 | 26 | The app is styled using `tailwindcss` (v4). The components are based on `react-aria-components`, and the styles are applied using a combination of `clsx` and `class-variance-authority`. The icons in the app are from `lucide-react`. 27 | 28 | *** 29 | 30 | The app can be built using `vite`. The `vite-plugin-pwa` plugin generates the required assets and service worker for the app to be **installable as a PWA on the device and work offline**. 31 | 32 | ## Project structure 33 | The project is an overview of the app implementation. It highlights the most relevant details about each dependency and how to combine them to implement a client-only app. 34 | 35 | Each module describes how a specific section of the app is structured: 36 | - **Local database**: PGlite and `drizzle` form the data layer, together with `Schema` from `effect` 37 | - **Live queries**: `live` extension of PGlite to read the database with real-time updates 38 | - **Routing**: TanStack Router with type-safe routes and loaders 39 | - **State management**: `xstate` using the actor model 40 | - **UI**: `react-aria-components` in combination with `tailwindcss` 41 | 42 | ### What's missing to make the app local-first? 43 | For an app to be local-first the implementation must start from the client. 44 | 45 | > This project defines the first step: making the app independent of the server and completely functional on the client even without internet access. 46 | 47 | The last step is *multi-device and collaboration support*. This could be achieved using a **sync engine**, which could be added to this project to sync the local database with a other devices. 48 | 49 | 50 | *** 51 | 52 | ## Prerequisites 53 | The project used TypeScript as programming language, so at least a **basic knowledge of the language is required**. 54 | 55 | Other than that, there are no special requirements. 56 | 57 | The app is client-side and works completely in the browser, so there is no complex configuration involved. 58 | 59 | > In fact, the app is contained in a single `app` folder, without any server-side code. 60 | 61 | A basic knowledge of `effect` is recommended to understand how the code is organized. 62 | 63 | > You can take a quick look at the [Effect: Beginners Complete Getting Started course](https://www.typeonce.dev/course/effect-beginners-complete-getting-started) to get familiar with the basics of `effect`. 64 | 65 | The local database setup using PGlite is explained in the course, so no previous knowledge is required. 66 | 67 | All other components are mostly optional and interchangeable, so the course just briefly explains their benefits without assuming any prior knowledge. In fact, you could swap any of the other components with your own preferred libraries. -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | out: "./src/drizzle", 5 | schema: "./src/schema/drizzle.ts", 6 | dialect: "postgresql", 7 | }); 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Local calories tracker 7 | 11 | 12 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calories-tracker-tanstack-start-pglite", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "typecheck": "tsc --noEmit", 8 | "dev": "vite --port=3001", 9 | "build": "vite build", 10 | "serve": "vite preview", 11 | "start": "vite", 12 | "generate": "pnpm drizzle-kit generate", 13 | "generate-assets": "pwa-assets-generator --preset minimal-2023 public/favicon.svg" 14 | }, 15 | "devDependencies": { 16 | "@tailwindcss/vite": "4.0.13", 17 | "@tanstack/router-plugin": "^1.114.15", 18 | "@types/react": "^19.0.8", 19 | "@types/react-dom": "^19.0.3", 20 | "@vite-pwa/assets-generator": "^0.2.6", 21 | "@vitejs/plugin-react": "^4.3.2", 22 | "drizzle-kit": "^0.28.0", 23 | "tailwindcss": "4.0.13", 24 | "typescript": "^5.8.2", 25 | "vite": "^5.4.8", 26 | "vite-plugin-pwa": "^0.21.0", 27 | "vite-tsconfig-paths": "^5.1.1" 28 | }, 29 | "dependencies": { 30 | "@electric-sql/pglite": "^0.2.17", 31 | "@electric-sql/pglite-react": "^0.2.17", 32 | "@tanstack/react-router": "^1.114.15", 33 | "@xstate/react": "^5.0.3", 34 | "class-variance-authority": "^0.7.0", 35 | "clsx": "^2.1.1", 36 | "drizzle-orm": "^0.36.1", 37 | "effect": "^3.13.10", 38 | "lucide-react": "^0.460.0", 39 | "react": "^19.0.0", 40 | "react-aria-components": "^1.7.1", 41 | "react-dom": "^19.0.0", 42 | "tailwind-merge": "^2.5.4", 43 | "xstate": "^5.19.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeonce-dev/calories-tracker-local-only-app/a2f584b63f57ffc5626171bc5bfeaed762d80cf0/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeonce-dev/calories-tracker-local-only-app/a2f584b63f57ffc5626171bc5bfeaed762d80cf0/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeonce-dev/calories-tracker-local-only-app/a2f584b63f57ffc5626171bc5bfeaed762d80cf0/public/maskable-icon-512x512.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeonce-dev/calories-tracker-local-only-app/a2f584b63f57ffc5626171bc5bfeaed762d80cf0/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeonce-dev/calories-tracker-local-only-app/a2f584b63f57ffc5626171bc5bfeaed762d80cf0/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeonce-dev/calories-tracker-local-only-app/a2f584b63f57ffc5626171bc5bfeaed762d80cf0/public/pwa-64x64.png -------------------------------------------------------------------------------- /src/components/CreateFood.tsx: -------------------------------------------------------------------------------- 1 | import { useMachine } from "@xstate/react"; 2 | import { machine } from "~/machines/manage-food"; 3 | import FoodEditing from "./FoodEditing"; 4 | import { Button } from "./ui/Button"; 5 | import { Dialog, DialogTrigger } from "./ui/Dialog"; 6 | import { Modal, ModalOverlay } from "./ui/Modal"; 7 | 8 | export default function CreateFood() { 9 | const [snapshot, send] = useMachine(machine, { input: undefined }); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | {({ close }) => ( 17 | send({ type: "food.create" })} 29 | > 30 | 37 | 38 | {snapshot.context.submitError !== null && ( 39 |

{snapshot.context.submitError}

40 | )} 41 |
42 | )} 43 |
44 |
45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/CreatePlan.tsx: -------------------------------------------------------------------------------- 1 | import { useMachine } from "@xstate/react"; 2 | import { Button, Form } from "react-aria-components"; 3 | import { machine } from "~/machines/create-plan"; 4 | import { _PlanInsert } from "~/schema/plan"; 5 | import QuantityField from "./QuantityField"; 6 | 7 | export default function CreatePlan() { 8 | const [snapshot, send] = useMachine(machine); 9 | 10 | if (snapshot.matches("Created")) { 11 | return

Plan created!

; 12 | } 13 | 14 | return ( 15 |
{ 17 | event.preventDefault(); 18 | send({ type: "plan.create" }); 19 | }} 20 | > 21 | 27 | 28 | 34 | 35 | 41 | 42 | 48 | 49 | 52 | 53 | {snapshot.context.submitError !== null && ( 54 |

{snapshot.context.submitError}

55 | )} 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/CreateServing.tsx: -------------------------------------------------------------------------------- 1 | import { useMachine } from "@xstate/react"; 2 | import { FilePlus2Icon } from "lucide-react"; 3 | import { Form } from "react-aria-components"; 4 | import { machine } from "~/machines/create-serving"; 5 | import { ServingInsert } from "~/schema/serving"; 6 | import type { Meal } from "~/schema/shared"; 7 | import QuantityField from "./QuantityField"; 8 | import { Button } from "./ui/Button"; 9 | import { Dialog, DialogTrigger } from "./ui/Dialog"; 10 | import { Modal, ModalOverlay } from "./ui/Modal"; 11 | 12 | export default function CreateServing({ 13 | meal, 14 | dailyLogDate, 15 | foodId, 16 | }: { 17 | foodId: number; 18 | meal: typeof Meal.Type; 19 | dailyLogDate: string; 20 | }) { 21 | const [snapshot, send] = useMachine(machine); 22 | return ( 23 | 24 | 27 | 28 | 29 | 30 | {({ close }) => ( 31 |
{ 34 | event.preventDefault(); 35 | send({ 36 | type: "quantity.confirm", 37 | meal, 38 | dailyLogDate, 39 | foodId, 40 | }); 41 | }} 42 | > 43 | 49 | 50 | 57 | 58 | {snapshot.context.submitError !== null && ( 59 |

{snapshot.context.submitError}

60 | )} 61 | 62 | )} 63 |
64 |
65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/DailyPlanCard.tsx: -------------------------------------------------------------------------------- 1 | import { Match } from "effect"; 2 | import { Group, Label, ProgressBar } from "react-aria-components"; 3 | import type { PlanSelectDaily } from "~/schema/plan"; 4 | import { cn } from "~/utils"; 5 | import { CarbohydrateIcon, FatIcon, ProteinIcon } from "./ui/Icons"; 6 | 7 | type Label = "fat" | "carbohydrate" | "protein"; 8 | const GramsForCalorie = Match.type