Compare commits

..

155 Commits

Author SHA1 Message Date
b5d8b50205 fix(deps): update dependency recharts to v3
Some checks failed
Lint / Lint and Typecheck (push) Failing after 39s
Lint / Lint and Typecheck (pull_request) Failing after 41s
2025-07-13 00:02:59 +00:00
fc452ebd4c pnpm ci
All checks were successful
Lint / Lint and Typecheck (push) Successful in 54s
2025-07-13 01:37:43 +02:00
201c1ee523 pnpm CI
Some checks failed
Lint / Lint and Typecheck (push) Failing after 5s
2025-07-13 01:35:03 +02:00
1c1b842a15 PNPM
Some checks failed
Lint / Lint and Typecheck (push) Failing after 6s
2025-07-13 01:15:15 +02:00
aba4e4a7f6 npm audit fix
All checks were successful
Lint / Lint and Typecheck (push) Successful in 44s
2025-07-13 00:58:58 +02:00
4dcd24f1fd fix(deps): update dependency zod to v4 (#6)
Some checks failed
Lint / Lint and Typecheck (push) Has been cancelled
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [zod](https://zod.dev) ([source](https://github.com/colinhacks/zod)) | [`^3.24.3` -> `^4.0.0`](https://renovatebot.com/diffs/npm/zod/3.25.76/4.0.5) | [![age](https://developer.mend.io/api/mc/badges/age/npm/zod/4.0.5?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zod/3.25.76/4.0.5?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>colinhacks/zod (zod)</summary>

### [`v4.0.5`](https://github.com/colinhacks/zod/releases/tag/v4.0.5)

[Compare Source](https://github.com/colinhacks/zod/compare/v4.0.4...v4.0.5)

#### Commits:

- [`f91a73e`](f91a73ec23) Support pipes in discriminated unions. Closes [#&#8203;4856](https://github.com/colinhacks/zod/issues/4856) ([#&#8203;4861](https://github.com/colinhacks/zod/issues/4861))
- [`45afab0`](45afab0f84) 4.0.5

### [`v4.0.4`](https://github.com/colinhacks/zod/releases/tag/v4.0.4)

[Compare Source](https://github.com/colinhacks/zod/compare/v4.0.3...v4.0.4)

#### Commits:

- [`9335f05`](9335f0543d) Adds `ZodFirstPartyTypeKind` stub to fix module resolution failure inside `zod-to-json-schema`

### [`v4.0.3`](https://github.com/colinhacks/zod/releases/tag/v4.0.3)

[Compare Source](44a936cb77...v4.0.3)

#### Commits:

- [`5905a8d`](5905a8d810) Improve check-versions script
- [`f3e749b`](f3e749b1b0) Remove global File interface
- [`44a936c`](44a936cb77) 4.0.2
- [`74006ed`](74006edd49) Fix JSR provenance
- [`ff4af5e`](ff4af5e889) 4.0.3
- [`ce573e8`](ce573e8799) Update test badge
- [`9a7161a`](9a7161a976) Fix versions

### [`v4.0.2`](https://github.com/colinhacks/zod/compare/v4.0.1...44a936cb77961e57a0988d8a3c63d9c71fce69ac)

[Compare Source](https://github.com/colinhacks/zod/compare/v4.0.1...44a936cb77961e57a0988d8a3c63d9c71fce69ac)

### [`v4.0.1`](https://github.com/colinhacks/zod/releases/tag/v4.0.1): v4.0.0

[Compare Source](79d4d80e3b...v4.0.1)

With this release, `zod@4.0.0` has been published to `npm`. There were no code changes between 3.25.76 and 4.0.0!

Zod 4 has been stable for the past 6 weeks, but it was published inside zod@3.25.x on npm. this transitionary window gave the ecosystem time to incrementally support for Zod 4 (without dropping support for Zod 3). As there is now near-universal support for Zod 4 in the ecosystem, ths time feels right to finally put a bow on things 🎀

To upgrade to Zod 4:

```
npm upgrade zod@^4.0.0
```

If you’ve already migrated to Zod 4 using the subpaths, there are no changes required. however you can optionally simplify your imports (recommended)

```ts
// after upgrading to zod@4.0.0:
import * as z from "zod"; // Zod 4 (regular)
import * as z from "zod/mini" // Zod 4 Mini

// these still work, but are no longer needed
import * as z from "zod/v4";
import * as z from "zod/v4-mini":

// if you still need Zod 3
import * as z from "zod/v3"; // Zod 3
```

**Library authors** — if you've already implemented Zod 4 support according to the best practices outlined in the [Library authors](/library-authors) guide, bump your peer dependency to include `zod@^4.0.0`:

```json
// package.json
{
  "peerDependencies": {
    "zod": "^3.25.0 || ^4.0.0"
  }
}
```

*There should be no other code changes necessary.* No code changes were made between the latest `3.25.x` release and `4.0.0`. This does not require a major version bump.

### [`v4.0.0`](https://github.com/colinhacks/zod/compare/v3.25.76...79d4d80e3b47f04752c5c281077b53f889551441)

[Compare Source](https://github.com/colinhacks/zod/compare/v3.25.76...79d4d80e3b47f04752c5c281077b53f889551441)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekend" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC40Ni4wIiwidXBkYXRlZEluVmVyIjoiNDEuMzEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: Felix Schulze <admin@schulze.network>
Reviewed-on: #6
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-07-13 00:58:35 +02:00
440b759daa chore(deps): update dependency typescript-eslint to v8.36.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 47s
2025-07-12 04:02:44 +00:00
9a54bdf93f chore(deps): update dependency eslint to v9.31.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-07-12 03:03:16 +00:00
cabcbbb84a fix(deps): update dependency zod to v3.25.76
All checks were successful
Lint / Lint and Typecheck (push) Successful in 47s
2025-07-12 02:03:43 +00:00
d815cab9d8 chore(deps): update dependency prettier-plugin-tailwindcss to v0.6.14
All checks were successful
Lint / Lint and Typecheck (push) Successful in 44s
2025-07-12 01:04:18 +00:00
be6a875999 chore(deps): update dependency @types/node to v22.16.3
All checks were successful
Lint / Lint and Typecheck (push) Successful in 52s
2025-07-12 00:03:12 +00:00
31c6c7106f fix(deps): update dependency react-hook-form to v7.60.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 50s
2025-07-05 06:01:54 +00:00
93c1320651 chore(deps): update dependency @types/node to v22.16.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 44s
2025-07-05 05:02:43 +00:00
b875d65fdc fix(deps): update nextjs monorepo to v15.3.5
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
2025-07-05 04:03:01 +00:00
26a305a96b fix(deps): update dependency zod to v3.25.74
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-07-05 03:03:24 +00:00
fb32bd381c chore(deps): update dependency typescript-eslint to v8.35.1
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-07-05 02:03:37 +00:00
c45cad8e96 chore(deps): update dependency tw-animate-css to v1.3.5
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-07-05 01:03:56 +00:00
86ee8c6b32 chore(deps): update dependency eslint to v9.30.1
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-07-05 00:02:57 +00:00
f6dd7c3012 chore(deps): update dependency @types/node to v22.15.34
All checks were successful
Lint / Lint and Typecheck (push) Successful in 49s
2025-06-28 08:02:13 +00:00
e34fc5dffc fix(deps): update dependency react-hook-form to v7.59.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-06-28 06:02:57 +00:00
57be648512 fix(deps): update dependency lucide-react to ^0.525.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 51s
2025-06-28 05:03:07 +00:00
c131fba360 chore(deps): update dependency typescript-eslint to v8.35.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
2025-06-28 04:03:27 +00:00
9803c3f33d chore(deps): update dependency prettier to v3.6.2
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-28 03:03:35 +00:00
7f7a8d8728 chore(deps): update dependency eslint to v9.30.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-28 02:04:13 +00:00
a2184c0ef2 chore(deps): update tailwindcss monorepo to v4.1.11
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-28 01:04:19 +00:00
198a8c8c45 chore(deps): update dependency @types/node to v22.15.33
All checks were successful
Lint / Lint and Typecheck (push) Successful in 52s
2025-06-28 00:03:05 +00:00
d87fba0ca5 fix(deps): update dependency lucide-react to ^0.522.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-06-21 09:02:16 +00:00
0cbc745798 fix(deps): update nextjs monorepo to v15.3.4
All checks were successful
Lint / Lint and Typecheck (push) Successful in 44s
2025-06-21 08:03:01 +00:00
e59620f619 fix(deps): update dependency zod to v3.25.67
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-06-21 07:02:26 +00:00
52eaa7bd4f fix(deps): update dependency recharts to v2.15.4
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-06-21 06:02:52 +00:00
614d1a83cc fix(deps): update dependency react-hook-form to v7.58.1
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
2025-06-21 05:03:16 +00:00
d29d4e54ef chore(deps): update dependency eslint to v9.29.0
All checks were successful
Lint / Lint and Typecheck (pull_request) Successful in 46s
Lint / Lint and Typecheck (push) Successful in 41s
2025-06-21 04:03:51 +00:00
c79f504c24 chore(deps): update dependency typescript-eslint to v8.34.1
All checks were successful
Lint / Lint and Typecheck (push) Successful in 47s
2025-06-21 03:03:56 +00:00
aa0c90e70b chore(deps): update dependency prettier-plugin-tailwindcss to v0.6.13
All checks were successful
Lint / Lint and Typecheck (push) Successful in 47s
2025-06-21 02:03:58 +00:00
b0a1512911 chore(deps): update dependency postcss to v8.5.6
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-06-21 01:04:40 +00:00
f34cbabff2 chore(deps): update dependency @types/node to v22.15.32
All checks were successful
Lint / Lint and Typecheck (push) Successful in 1m6s
2025-06-21 00:03:26 +00:00
1b58443e69 add IndexNow key
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
2025-06-16 11:25:20 +02:00
5fdeae83c6 fix(deps): update dependency react-hook-form to v7.58.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-06-15 12:02:26 +00:00
fb62ae2011 fix(deps): update dependency lucide-react to ^0.515.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 47s
2025-06-14 10:02:46 +00:00
55c8b7c079 chore(deps): update dependency typescript-eslint to v8.34.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-06-14 09:03:08 +00:00
e39ddfd7a6 fix(deps): update dependency zod to v3.25.64
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
2025-06-14 08:03:23 +00:00
990ef286c8 fix(deps): update dependency tailwind-merge to v3.3.1
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-06-14 07:03:49 +00:00
dc80ce98e5 fix(deps): update dependency @t3-oss/env-nextjs to v0.13.8
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-14 06:04:06 +00:00
f1b29f7c5f fix(deps): update dependency @hookform/resolvers to v5.1.1
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-14 05:04:08 +00:00
e4416ba3df chore(deps): update tailwindcss monorepo to v4.1.10
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-14 04:04:23 +00:00
3f0584ab51 chore(deps): update npm to v11.4.2
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-14 03:04:36 +00:00
b657c589e0 chore(deps): update dependency postcss to v8.5.5
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-14 02:04:43 +00:00
c008289e09 chore(deps): update dependency @types/react to v19.1.8
All checks were successful
Lint / Lint and Typecheck (push) Successful in 41s
2025-06-14 01:05:21 +00:00
3cee1fff9e chore(deps): update dependency @types/node to v22.15.31
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-06-14 00:04:05 +00:00
07de9a0062 fix(deps): update dependency @hookform/resolvers to v5.1.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 46s
2025-06-07 14:02:12 +00:00
00108ab629 fix(deps): update dependency react-hook-form to v7.57.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 50s
2025-06-07 06:02:59 +00:00
d790a8bb3b fix(deps): update dependency lucide-react to ^0.513.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 41s
2025-06-07 05:03:06 +00:00
24e6a4ef95 fix(deps): update dependency zod to v3.25.56
All checks were successful
Lint / Lint and Typecheck (push) Successful in 40s
2025-06-07 04:03:21 +00:00
5ddd9cc58f chore(deps): update dependency typescript-eslint to v8.33.1
All checks were successful
Lint / Lint and Typecheck (push) Successful in 40s
2025-06-07 03:03:57 +00:00
adcdce67cb chore(deps): update dependency tw-animate-css to v1.3.4
All checks were successful
Lint / Lint and Typecheck (push) Successful in 40s
2025-06-07 02:04:01 +00:00
51c4bd7316 chore(deps): update dependency @types/react-dom to v19.1.6
All checks were successful
Lint / Lint and Typecheck (push) Successful in 39s
2025-06-07 01:04:21 +00:00
bff9c98db1 chore(deps): update dependency @types/node to v22.15.30
All checks were successful
Lint / Lint and Typecheck (push) Successful in 4m27s
2025-06-07 00:03:31 +00:00
bedaa2090f chore(deps): update dependency tw-animate-css to v1.3.3
All checks were successful
Lint / Lint and Typecheck (push) Successful in 50s
2025-06-01 10:02:04 +00:00
71df024aa9 fix(deps): update dependency zod to v3.25.46
All checks were successful
Lint / Lint and Typecheck (push) Successful in 49s
2025-06-01 08:02:07 +00:00
228a0bdaaf fix(deps): update dependency zod to v3.25.45
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
2025-06-01 02:02:06 +00:00
a4a3ed403b fix(deps): update dependency zod to v3.25.43
All checks were successful
Lint / Lint and Typecheck (push) Successful in 53s
2025-05-31 23:02:06 +00:00
863d4f3268 chore(deps): update dependency typescript-eslint to v8.33.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-05-31 09:44:14 +00:00
9719383056 chore(deps): update dependency eslint to v9.28.0 2025-05-31 09:02:47 +00:00
6c77a71a79 fix(deps): update nextjs monorepo to v15.3.3 2025-05-31 08:02:53 +00:00
b54c8e3d2b fix(deps): update dependency zod to v3.25.42 2025-05-31 07:03:51 +00:00
1cffa649d6 fix(deps): update dependency @t3-oss/env-nextjs to v0.13.6 2025-05-31 06:04:06 +00:00
7c38ed2a60 chore(deps): update tailwindcss monorepo to v4.1.8 2025-05-31 05:04:09 +00:00
7a69e9ff45 chore(deps): update dependency tw-animate-css to v1.3.2 2025-05-31 04:03:47 +00:00
e71f28d8c2 chore(deps): update dependency prettier-plugin-tailwindcss to v0.6.12 2025-05-31 03:03:56 +00:00
d26a3252cc chore(deps): update dependency postcss to v8.5.4 2025-05-31 02:04:10 +00:00
2a7182625b chore(deps): update dependency @types/react to v19.1.6 2025-05-31 01:04:46 +00:00
d7cbcf3707 chore(deps): update dependency @types/node to v22.15.29 2025-05-31 00:03:33 +00:00
374cb17eeb fix(deps): update dependency zod to v3.25.28 2025-05-24 04:02:38 +00:00
d5f8c84f13 fix(deps): update radix-ui-primitives monorepo 2025-05-24 03:02:37 +00:00
d116e01f8d chore(deps): update npm to v11.4.1 2025-05-24 02:02:50 +00:00
a297b8a4aa chore(deps): update dependency @types/react to v19.1.5 2025-05-24 01:03:08 +00:00
acb4e5bc51 chore(deps): update dependency @types/node to v22.15.21 2025-05-24 00:02:25 +00:00
606512fad8 logo priority and unoptimised 2025-05-22 16:23:24 +02:00
a5d07b8b3e fix(deps): update dependency lucide-react to ^0.511.0 2025-05-17 08:01:55 +00:00
0a7a51ef64 chore(deps): update npm to v11.4.0 2025-05-17 07:02:05 +00:00
2d5f9b051d chore(deps): update dependency tw-animate-css to v1.3.0 2025-05-17 06:02:26 +00:00
d83ef08f7c chore(deps): update dependency eslint to v9.27.0 2025-05-17 05:02:40 +00:00
7362a6545b fix(deps): update dependency react-hook-form to v7.56.4 2025-05-17 04:02:47 +00:00
553f5155c7 chore(deps): update tailwindcss monorepo to v4.1.7 2025-05-17 03:03:01 +00:00
44a98fe001 chore(deps): update react monorepo 2025-05-17 02:03:11 +00:00
0736907b84 chore(deps): update dependency typescript-eslint to v8.32.1 2025-05-17 01:03:20 +00:00
4f4a74dc72 chore(deps): update dependency @types/node to v22.15.18 2025-05-17 00:02:29 +00:00
6387e96bf7 fix(deps): update dependency tailwind-merge to v3.3.0 2025-05-11 21:01:27 +00:00
96f95e7b08 color y axis and fix type error
closes #3
2025-05-10 22:13:30 +02:00
87ea9e1ecc fix(deps): update dependency lucide-react to ^0.509.0 2025-05-10 02:02:24 +00:00
51add59741 chore(deps): update tailwindcss monorepo to v4.1.6 2025-05-10 01:03:13 +00:00
29d42c0f22 chore(deps): update dependency @types/node to v22.15.17 2025-05-10 00:02:10 +00:00
e36c062b9c fix(deps): update dependency lucide-react to ^0.508.0 2025-05-08 14:13:41 +02:00
8273fce712 chore(deps): update dependency typescript-eslint to v8.32.0 2025-05-08 14:13:36 +02:00
820d7f4883 fix(deps): update radix-ui-primitives monorepo 2025-05-08 14:13:25 +02:00
6d487f8792 fix(deps): update nextjs monorepo to v15.3.2 2025-05-08 14:13:19 +02:00
f4ab9d3745 fix(deps): update dependency react-hook-form to v7.56.3 2025-05-08 14:13:14 +02:00
e1da910a25 chore(deps): update dependency @types/react to v19.1.3 2025-05-08 14:13:03 +02:00
225f9ef1ab chore(deps): update dependency @types/node to v22.15.16 2025-05-08 11:11:52 +00:00
c071b9c052 improve SEO, add robots 2025-05-07 14:37:34 +02:00
11e1e31ac5 update default inflation rate to average inflation 2025-05-07 14:10:33 +02:00
8ac784f49b update favicon.ico 2025-05-05 13:51:55 +02:00
0b0e6c1c9a fix(deps): update dependency lucide-react to ^0.507.0 2025-05-05 10:33:47 +00:00
c3168220fe chore(deps): update dependency tw-animate-css to v1.2.9 2025-05-05 10:11:30 +00:00
229f2d7b56 chore(deps): update dependency @types/react-dom to v19.1.3 2025-05-05 10:01:35 +00:00
a86eddda31 chore(deps): pin dependencies 2025-05-05 09:55:30 +00:00
0a96a94cf3 move workflow check.yml 2025-05-05 11:51:58 +02:00
0e6086a597 chore(deps): pin dependencies 2025-05-05 11:49:10 +02:00
dafdd0d154 chore(deps): update tailwindcss monorepo to ^4.1.4 2025-05-05 11:37:00 +02:00
b9b52377e0 fix(deps): update dependency zod to v3.24.4 2025-05-05 11:35:25 +02:00
63fe8e5999 fix(deps): update dependency @t3-oss/env-nextjs to ^0.13.0 2025-05-05 11:35:19 +02:00
fc18e414cb fix(deps): update dependency react-hook-form to v7.56.2 2025-05-05 11:34:43 +02:00
a3eda1f0db chore(deps): update npm to v11.3.0 2025-05-05 11:33:47 +02:00
2b2b5784d1 chore(deps): update dependency eslint to ^9.26.0 2025-05-05 11:31:59 +02:00
32ef797bf6 chore(deps): update dependency @types/node to v22 (#2)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)) | devDependencies | major | [`^20.14.10` -> `^22.0.0`](https://renovatebot.com/diffs/npm/@types%2fnode/20.17.32/22.15.3) |

---

### Configuration

📅 **Schedule**: Branch creation - "every weekend" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC4xLjIiLCJ1cGRhdGVkSW5WZXIiOiI0MC4xLjIiLCJ0YXJnZXRCcmFuY2giOiJtYWluIiwibGFiZWxzIjpbXX0=-->

Reviewed-on: #2
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-05-05 11:31:04 +02:00
02761928a5 add check action 2025-05-05 11:07:25 +02:00
763c8b590d Add Renovate bot 2025-05-05 11:02:51 +02:00
e616e8f261 Epic README 2025-05-04 20:35:39 +02:00
a0c5665941 add optional "show 4%-rule" button with extra cards and reference lines 2025-05-04 19:44:18 +02:00
d24c8b910a funky background pattern 2025-05-04 17:25:04 +02:00
8f7ebf7b5a sitemap 2025-05-03 18:04:34 +02:00
7c05542d5e add theme color 2025-05-02 19:06:27 +02:00
32a0b7a0ac rename icons again 2025-05-02 18:59:39 +02:00
c1e57577cd fix plausible provider and favicon declaration 2025-05-02 18:55:05 +02:00
d2735a7020 change favicon names 2025-05-02 18:37:28 +02:00
4348e4bdf3 remove scripts from package 2025-05-01 20:16:26 +02:00
4026924e06 update SEO texts 2025-05-01 20:14:21 +02:00
bf9098e3e5 add footer 2025-05-01 20:13:08 +02:00
1082dc3b69 fix allowance y-axis and chart styling 2025-05-01 20:12:55 +02:00
670ed01ede auto update on value change 2025-05-01 17:24:45 +02:00
2bc1d42cf7 break out functions from export 2025-05-01 15:56:01 +02:00
23e03c9a32 redesigned algorith, use user specified retirement age 2025-05-01 15:25:22 +02:00
fdd923cfbc shadcn slider 2025-05-01 13:57:54 +02:00
6a6557c3bf new strategy human algo 2025-04-30 23:17:48 +02:00
5544c2f69f styling, graph sizing and number precision 2025-04-30 20:05:38 +02:00
24547c3087 result style 2025-04-30 19:40:53 +02:00
6a6f0ee9a5 prettier 2025-04-30 19:20:25 +02:00
439b7c395c fix faq chevron 2025-04-30 18:12:23 +02:00
d761ac0348 add logo 2025-04-30 18:06:14 +02:00
bc08871f86 fix lint errors 2025-04-29 22:49:23 +02:00
a032a132e4 attempt new formula 2025-04-29 20:29:56 +02:00
54ed15ff25 lets not be so strict 2025-04-29 20:25:49 +02:00
cdb67cae95 Select 2025-04-29 20:15:54 +02:00
5888b46b25 tracking + web vitals 2025-04-29 20:09:37 +02:00
26ceef1740 env 2025-04-29 20:09:07 +02:00
857f1a242b SEO 2025-04-29 19:55:15 +02:00
9531fcea99 SEO 2025-04-29 19:32:09 +02:00
2be1a6b947 fixes 2025-04-29 19:22:01 +02:00
dd40e92179 FIRE chart 2025-04-29 19:11:09 +02:00
23c2c0ea21 new algorithm 2025-04-29 18:45:58 +02:00
3660cc0310 fix and add charts 2025-04-29 18:45:41 +02:00
6018239c43 SEO 2025-04-29 18:33:19 +02:00
97fa489d6c FIRE calculator 2025-04-29 18:32:26 +02:00
a1e9b667f3 shadcn 2025-04-29 17:46:38 +02:00
ab5eb23238 initial files 2025-04-29 17:09:04 +02:00
32 changed files with 6669 additions and 7437 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.png filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text

View File

@@ -0,0 +1,31 @@
name: Lint
on:
pull_request:
push:
branches:
- "**" # matches every branch
jobs:
lint_and_typecheck:
name: Lint and Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Run check
run: pnpm run check

109
README.md
View File

@@ -1,3 +1,108 @@
# fire ![InvestingFIRE logo](/src/app/apple-icon.png)
FIRE calculator # InvestingFIRE 🔥 — The #1 Interactive FIRE Calculator
**InvestingFIRE** is a responsive web application for calculating your path to Financial Independence and Early Retirement (FIRE). It features a year-by-year projection engine that simulates both accumulation (savings and investment growth) and retirement (withdrawals) phases, allowing users to:
- Input starting capital, monthly savings, expected annual growth rate, inflation rate, current age, desired retirement age, life expectancy, and desired monthly retirement allowance.
- View a dynamic chart displaying projected portfolio balance and monthly allowance over time.
- Instantly see their estimated “FIRE number” (required capital at retirement), how long their capital will last, and compare results to the “4% rule.”
- Adjust assumptions live, with all calculations and visualizations updating automatically.
- Access explanatory content about FIRE methodology, key variables, and additional community resources, all on a single, consolidated page.
The projects code is structured using React/Next.js with TypeScript, focusing on user experience, modern UI components, and clarity of financial assumptions.
---
## 🚀 Features at a Glance
- **⚡️ Real-Time Projections:** Every field updates the chart _as you type_. Experiment with savings, growth rates, inflation, or retirement age and see your future instantly.
- **📈 Interactive Chart:** Dual-area plots for portfolio value and future monthly spending, plus reference lines for FIRE milestones and “4% rule” legends.
- **🧠 Education Baked In:** Contextual tooltips, deep-dive sections on how FIRE works, FAQs, and must-read resources included.
- **🔎 Detailed Methodology:** Not just a 25x rule — runs a full, year-by-year simulation with inflation-adjusted withdrawals and optional 4%-rule overlays.
- **👌 Modern UX:** Typing, sliding, or clicking feels _good_. Responsive on all devices.
---
## 🧰 How It Works
The calculator models your FIRE journey in two phases:
1. **Accumulation:**
- Your starting capital is grown by your expected CAGR (~7% by default).
- Monthly savings are added for each year until retirement.
- Every variable can be adjusted live (capital, savings, age, growth, inflation, spending, target retirement).
2. **Retirement:**
- Your balance continues to grow by CAGR.
- Each year, an inflation-adjusted monthly allowance is withdrawn.
- The simulation runs until your selected life expectancy, showing the possibility of portfolio depletion.
**Key Outputs:**
- 🔥 “FIRE Number”: Portfolio value at your defined retirement age
- 📊 Interactive projection chart: See how your nest egg and withdrawals evolve over time
- 4⃣ “4% Rule” overlays: Compare dynamic results to classic rule-of-thumb
---
## 🌟 Try It For Yourself
To run locally:
1. **Clone the repo**
```bash
git clone https://git.schulze.network/schulze/fire.git
cd fire
```
2. **Install dependencies**
```bash
pnpm install
```
3. **Run the app**
```bash
pnpm run dev
```
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
Deployed version: [https://investingfire.com](https://investingfire.com)
---
## ✏️ Inputs & Variables
- **Starting Capital** — How much youve already invested
- **Monthly Savings** — What youll add each month
- **Current Age & Retirement Age** — Your FI timeline
- **Life Expectancy** — How long do you want income to last?
- **Expected Growth Rate (CAGR)** — Portfolio annual % return, before inflation
- **Inflation Rate** — Cost of living increases
- **Desired Monthly Allowance** — Your lifestyle, todays dollars
As you adjust these, all projections update instantly _without needing to hit “Calculate.”_
Try many “what ifs” fast.
---
## 👩‍💻 Contributing
Pull requests are welcome! Open issues for bugs, new features, or debate about safe withdrawal rates and tax assumptions.
---
## 📄 License
[GPL-3.0](./LICENSE)
---
## 🥇 Why Use InvestingFIRE?
- You want the truth — not just a 4% fantasy.
- You want to learn, not just punch in numbers.
- You want clarity, speed, and modern UI.
- You want to show your friends the best FIRE tool on the web.
Enjoy the _rocket ride_ to financial independence.
**InvestingFIRE — Know your number. Change your future.**

View File

@@ -14,7 +14,7 @@ export default tseslint.config(
files: ["**/*.ts", "**/*.tsx"], files: ["**/*.ts", "**/*.tsx"],
extends: [ extends: [
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...tseslint.configs.strictTypeChecked, ...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked, ...tseslint.configs.stylisticTypeChecked,
], ],
rules: { rules: {

6765
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,47 +9,49 @@
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start"
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-label": "^2.1.4", "@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.13.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.503.0", "lucide-react": "^0.525.0",
"next": "^15.2.3", "next": "^15.2.3",
"next-plausible": "^3.12.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.56.1", "react-hook-form": "^7.56.1",
"recharts": "^2.15.3", "recharts": "^3.0.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"zod": "^3.24.3" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "3.3.1",
"@tailwindcss/postcss": "^4.0.15", "@tailwindcss/postcss": "4.1.11",
"@types/node": "^20.14.10", "@types/node": "22.16.3",
"@types/react": "^19.0.0", "@types/react": "19.1.8",
"@types/react-dom": "^19.0.0", "@types/react-dom": "19.1.6",
"eslint": "^9.23.0", "eslint": "9.31.0",
"eslint-config-next": "^15.2.3", "eslint-config-next": "15.3.5",
"postcss": "^8.5.3", "eslint-plugin-react-hooks": "^5.2.0",
"prettier": "^3.5.3", "postcss": "8.5.6",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier": "3.6.2",
"tailwindcss": "^4.0.15", "prettier-plugin-tailwindcss": "0.6.14",
"tw-animate-css": "^1.2.8", "tailwindcss": "4.1.11",
"typescript": "^5.8.2", "tw-animate-css": "1.3.5",
"typescript-eslint": "^8.27.0" "typescript": "5.8.3",
"typescript-eslint": "8.36.0"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
}, },
"packageManager": "npm@11.2.0" "packageManager": "pnpm@10.13.1"
} }

5070
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- sharp

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/web-app-manifest-192x192.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
public/web-app-manifest-512x512.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk

11
renovate.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": ["config:best-practices", ":semanticCommits"],
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true,
"automergeType": "branch"
}
]
}

BIN
src/app/apple-icon.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@@ -0,0 +1,157 @@
"use client";
import { useState, useEffect } from "react";
import {
type LucideIcon,
HandCoins,
Bitcoin,
Coins,
DollarSign,
Euro,
IndianRupee,
JapaneseYen,
PiggyBank,
PoundSterling,
Wallet,
Banknote,
ChartCandlestick,
CirclePercent,
CreditCard,
Gem,
Receipt,
ShoppingBasket,
Rocket,
RockingChair,
Sparkles,
ChartPie,
ChartBar,
BarChart3,
ChartLine,
TrendingDown,
TrendingUp,
Vault,
Landmark,
Briefcase,
Handshake,
Shield,
Lock,
CalendarRange,
Hourglass,
Sprout,
Target,
} from "lucide-react";
export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [rows, setRows] = useState(0);
const [columns, setColumns] = useState(0);
useEffect(() => {
const updateDimensions = () => {
if (window.innerWidth > width + spacing * 2) {
setWidth(window.innerWidth);
}
if (window.innerHeight > height + spacing * 2) {
setHeight(window.innerHeight);
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => {
window.removeEventListener("resize", updateDimensions);
};
}, [height, width, spacing]);
useEffect(() => {
setColumns(Math.ceil(width / spacing) + 3);
}, [width, spacing]);
useEffect(() => {
setRows(Math.ceil(height / spacing) + 3);
}, [height, spacing]);
// Explicitly type the array as LucideIcon[]
const iconComponents: LucideIcon[] = [
HandCoins,
Bitcoin,
Coins,
DollarSign,
Euro,
IndianRupee,
JapaneseYen,
PiggyBank,
PoundSterling,
Wallet,
Banknote,
ChartCandlestick,
CirclePercent,
CreditCard,
Gem,
Receipt,
ShoppingBasket,
Rocket,
RockingChair,
Sparkles,
ChartPie,
ChartBar,
BarChart3,
ChartLine,
TrendingDown,
TrendingUp,
Vault,
Landmark,
Briefcase,
Handshake,
Shield,
Lock,
CalendarRange,
Hourglass,
Sprout,
Target,
];
const renderIcons = ({
rows,
columns,
}: {
rows: number;
columns: number;
}) => {
const icons = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < columns; x++) {
// Pick a random icon component from the array
const randomIndex = Math.floor(Math.random() * iconComponents.length);
const IconComponent = iconComponents[randomIndex]!;
// Slightly randomize size and position for more organic feel
const size = 28 + Math.floor(Math.random() * 8);
const xOffset = Math.floor(Math.random() * (spacing / 1.618));
const yOffset = Math.floor(Math.random() * (spacing / 1.618));
icons.push(
<IconComponent
key={`icon-${x}-${y}`}
size={size}
className="text-primary fixed"
style={{
left: `${x * spacing + xOffset}px`,
top: `${y * spacing + yOffset}px`,
opacity: opacity,
transform: `rotate(${Math.round((Math.random() - 0.5) * 30)}deg)`,
}}
/>,
);
}
}
return icons;
};
return (
<div className="absolute h-full w-full">
{width > 0 && renderIcons({ rows, columns })}
</div>
);
}

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -8,7 +7,6 @@ import * as z from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Form, Form,
FormControl, FormControl,
@@ -32,17 +30,25 @@ import {
XAxis, XAxis,
YAxis, YAxis,
ReferenceLine, ReferenceLine,
type TooltipProps,
} from "recharts"; } from "recharts";
import { Slider } from "@/components/ui/slider";
import assert from "assert";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
// Schema for form validation // Schema for form validation
const formSchema = z.object({ const formSchema = z.object({
startingCapital: z.coerce startingCapital: z.coerce.number(),
.number()
.min(0, "Starting capital must be a non-negative number"),
monthlySavings: z.coerce monthlySavings: z.coerce
.number() .number()
.min(0, "Monthly savings must be a non-negative number"), .min(0, "Monthly savings must be a non-negative number"),
currentAge: z.coerce.number().min(18, "Age must be at least 18"), currentAge: z.coerce
.number()
.min(1, "Age must be at least 1")
.max(100, "No point in starting this late"),
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"), cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
desiredMonthlyAllowance: z.coerce desiredMonthlyAllowance: z.coerce
.number() .number()
@@ -52,41 +58,79 @@ const formSchema = z.object({
.min(0, "Inflation rate must be a non-negative number"), .min(0, "Inflation rate must be a non-negative number"),
lifeExpectancy: z.coerce lifeExpectancy: z.coerce
.number() .number()
.min(50, "Life expectancy must be at least 50"), .min(40, "Be a bit more optimistic buddy :(")
.max(100, "You should be more realistic..."),
retirementAge: z.coerce
.number()
.min(18, "Retirement age must be at least 18")
.max(100, "Retirement age must be at most 100"),
}); });
// Type for form values // Type for form values
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
interface CalculationResult { interface YearlyData {
fireNumber: number | null;
retirementAge: number | null;
inflationAdjustedAllowance: number | null;
retirementYears: number | null;
error?: string;
yearlyData?: Array<{
age: number; age: number;
year: number; year: number;
balance: number; balance: number;
untouchedBalance: number;
phase: "accumulation" | "retirement"; phase: "accumulation" | "retirement";
}>; monthlyAllowance: number;
untouchedMonthlyAllowance: number;
} }
interface CalculationResult {
fireNumber: number | null;
fireNumber4percent: number | null;
retirementAge4percent: number | null;
yearlyData: YearlyData[];
error?: string;
}
// Helper function to format currency without specific symbols
const formatNumber = (value: number | null) => {
if (!value) return "N/A";
return new Intl.NumberFormat("en", {
maximumFractionDigits: 0,
}).format(value);
};
// Helper function to render tooltip for chart
const tooltipRenderer = ({
active,
payload,
}: TooltipProps<ValueType, NameType>) => {
if (active && payload?.[0]?.payload) {
const data = payload[0].payload as YearlyData;
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
};
export default function FireCalculatorForm() { export default function FireCalculatorForm() {
const [result, setResult] = useState<CalculationResult | null>(null); const [result, setResult] = useState<CalculationResult | null>(null);
const currentYear = new Date().getFullYear(); const irlYear = new Date().getFullYear();
const [showing4percent, setShowing4percent] = useState(false);
// Initialize form with default values // Initialize form with default values
const form = useForm<FormValues>({ const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
startingCapital: 50000, startingCapital: 50000,
monthlySavings: 1500, monthlySavings: 1500,
currentAge: 25, currentAge: 25,
cagr: 7, cagr: 7,
desiredMonthlyAllowance: 2000, desiredMonthlyAllowance: 3000,
inflationRate: 2, inflationRate: 2.3,
lifeExpectancy: 84, lifeExpectancy: 84,
retirementAge: 55,
}, },
}); });
@@ -95,229 +139,105 @@ export default function FireCalculatorForm() {
const startingCapital = values.startingCapital; const startingCapital = values.startingCapital;
const monthlySavings = values.monthlySavings; const monthlySavings = values.monthlySavings;
const currentAge = values.currentAge; const age = values.currentAge;
const annualGrowthRate = values.cagr / 100; const annualGrowthRate = 1 + values.cagr / 100;
const initialMonthlyAllowance = values.desiredMonthlyAllowance; const initialMonthlyAllowance = values.desiredMonthlyAllowance;
const annualInflation = values.inflationRate / 100; const annualInflation = 1 + values.inflationRate / 100;
const lifeExpectancy = values.lifeExpectancy; const ageOfDeath = values.lifeExpectancy;
const retirementAge = values.retirementAge;
const monthlyGrowthRate = Math.pow(1 + annualGrowthRate, 1 / 12) - 1;
const monthlyInflationRate = Math.pow(1 + annualInflation, 1 / 12) - 1;
const maxIterations = 1000; // Safety limit for iterations
// Binary search for the required retirement capital
let low = initialMonthlyAllowance * 12; // Minimum: one year of expenses
let high = initialMonthlyAllowance * 12 * 100; // Maximum: hundred years of expenses
let requiredCapital = 0;
let retirementAge = 0;
let finalInflationAdjustedAllowance = 0;
// First, find when retirement is possible with accumulation phase
let canRetire = false;
let currentCapital = startingCapital;
let age = currentAge;
let monthlyAllowance = initialMonthlyAllowance;
let iterations = 0;
// Array to store yearly data for the chart // Array to store yearly data for the chart
const yearlyData: CalculationResult["yearlyData"] = []; const yearlyData: YearlyData[] = [];
// Add starting point // Initial year data
yearlyData.push({
age: age,
year: irlYear,
balance: startingCapital,
untouchedBalance: startingCapital,
phase: "accumulation",
monthlyAllowance: 0,
untouchedMonthlyAllowance: initialMonthlyAllowance,
});
// Calculate accumulation phase (before retirement)
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
const currentAge = age + (year - irlYear);
const previousYearData = yearlyData[yearlyData.length - 1];
const inflatedAllowance =
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
const isRetirementYear = currentAge >= retirementAge;
const phase = isRetirementYear ? "retirement" : "accumulation";
assert(!!previousYearData);
// Calculate balance based on phase
let newBalance;
if (phase === "accumulation") {
// During accumulation: grow previous balance + add savings
newBalance =
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
} else {
// During retirement: grow previous balance - withdraw allowance
newBalance =
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
}
const untouchedBalance =
previousYearData.untouchedBalance * annualGrowthRate +
monthlySavings * 12;
const allowance = phase === "retirement" ? inflatedAllowance : 0;
yearlyData.push({ yearlyData.push({
age: currentAge, age: currentAge,
year: currentYear, year: year,
balance: startingCapital, balance: newBalance,
phase: "accumulation", untouchedBalance: untouchedBalance,
}); phase: phase,
monthlyAllowance: allowance,
// Accumulation phase simulation untouchedMonthlyAllowance: inflatedAllowance,
while (age < lifeExpectancy && iterations < maxIterations) {
// Simulate one year of saving and growth
for (let month = 0; month < 12; month++) {
currentCapital += monthlySavings;
currentCapital *= 1 + monthlyGrowthRate;
// Update allowance for inflation
monthlyAllowance *= 1 + monthlyInflationRate;
}
age++;
iterations++;
// Record yearly data
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
// Check each possible retirement capital target through binary search
const mid = (low + high) / 2;
if (high - low < 1) {
// Binary search converged
requiredCapital = mid;
break;
}
// Test if this retirement capital is sufficient
let testCapital = mid;
let testAge = age;
let testAllowance = monthlyAllowance;
let isSufficient = true;
// Simulate retirement phase with this capital
while (testAge < lifeExpectancy) {
for (let month = 0; month < 12; month++) {
// Withdraw inflation-adjusted allowance
testCapital -= testAllowance;
// Grow remaining capital
testCapital *= 1 + monthlyGrowthRate;
// Adjust allowance for inflation
testAllowance *= 1 + monthlyInflationRate;
}
testAge++;
// Check if we've depleted capital before life expectancy
if (testCapital <= 0) {
isSufficient = false;
break;
}
}
if (isSufficient) {
high = mid; // This capital or less might be enough
if (currentCapital >= mid) {
// We can retire now with this capital
canRetire = true;
retirementAge = age;
requiredCapital = mid;
finalInflationAdjustedAllowance = monthlyAllowance;
break;
}
} else {
low = mid; // We need more capital
}
}
// If we didn't find retirement possible in the loop
if (!canRetire && iterations < maxIterations) {
// Continue accumulation phase until we reach sufficient capital
while (age < lifeExpectancy && iterations < maxIterations) {
// Simulate one year
for (let month = 0; month < 12; month++) {
currentCapital += monthlySavings;
currentCapital *= 1 + monthlyGrowthRate;
monthlyAllowance *= 1 + monthlyInflationRate;
}
age++;
iterations++;
// Record yearly data
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
// Test with current capital
let testCapital = currentCapital;
let testAge = age;
let testAllowance = monthlyAllowance;
let isSufficient = true;
// Simulate retirement with current capital
while (testAge < lifeExpectancy) {
for (let month = 0; month < 12; month++) {
testCapital -= testAllowance;
testCapital *= 1 + monthlyGrowthRate;
testAllowance *= 1 + monthlyInflationRate;
}
testAge++;
if (testCapital <= 0) {
isSufficient = false;
break;
}
}
if (isSufficient) {
canRetire = true;
retirementAge = age;
requiredCapital = currentCapital;
finalInflationAdjustedAllowance = monthlyAllowance;
break;
}
}
}
// If retirement is possible, simulate the retirement phase for the chart
if (canRetire) {
// Update the phase for all years after retirement
yearlyData.forEach((data) => {
if (data.age >= retirementAge) {
data.phase = "retirement";
}
});
// Continue simulation for retirement phase if needed
let simulationCapital = currentCapital;
let simulationAllowance = monthlyAllowance;
let simulationAge = age;
// If we haven't simulated up to life expectancy, continue
while (simulationAge < lifeExpectancy) {
for (let month = 0; month < 12; month++) {
simulationCapital -= simulationAllowance;
simulationCapital *= 1 + monthlyGrowthRate;
simulationAllowance *= 1 + monthlyInflationRate;
}
simulationAge++;
// Record yearly data
yearlyData.push({
age: simulationAge,
year: currentYear + (simulationAge - currentAge),
balance: Math.round(simulationCapital),
phase: "retirement",
}); });
} }
}
if (canRetire) { // Calculate FIRE number at retirement
setResult({ const retirementYear = irlYear + (retirementAge - age);
fireNumber: requiredCapital, const retirementIndex = yearlyData.findIndex(
retirementAge: retirementAge, (data) => data.year === retirementYear,
inflationAdjustedAllowance: finalInflationAdjustedAllowance, );
retirementYears: lifeExpectancy - retirementAge, const retirementData = yearlyData[retirementIndex];
yearlyData: yearlyData,
error: undefined, const [fireNumber4percent, retirementAge4percent] = (() => {
}); for (const yearData of yearlyData) {
} else { if (
yearData.untouchedBalance >
(yearData.untouchedMonthlyAllowance * 12) / 0.04
) {
return [yearData.untouchedBalance, yearData.age];
}
}
return [0, 0];
})();
if (retirementIndex === -1 || !retirementData) {
setResult({ setResult({
fireNumber: null, fireNumber: null,
retirementAge: null, fireNumber4percent: null,
inflationAdjustedAllowance: null, retirementAge4percent: null,
retirementYears: null, error: "Could not calculate retirement data",
yearlyData: yearlyData,
});
} else {
// Set the result
setResult({
fireNumber: retirementData.balance,
fireNumber4percent: fireNumber4percent,
retirementAge4percent: retirementAge4percent,
yearlyData: yearlyData, yearlyData: yearlyData,
error:
iterations >= maxIterations
? "Calculation exceeded maximum iterations."
: "Cannot reach FIRE goal before life expectancy with current parameters.",
}); });
} }
} }
// Helper function to format currency without specific symbols
const formatNumber = (value: number | null) => {
if (value === null) return "N/A";
return new Intl.NumberFormat("en", {
maximumFractionDigits: 0,
}).format(value);
};
return ( return (
<div className="w-full max-w-3xl"> <>
<Card className="mb-8"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">FIRE Calculator</CardTitle> <CardTitle className="text-2xl">FIRE Calculator</CardTitle>
<CardDescription> <CardDescription>
@@ -338,7 +258,18 @@ export default function FireCalculatorForm() {
<Input <Input
placeholder="e.g., 10000" placeholder="e.g., 10000"
type="number" type="number"
{...field} value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -355,7 +286,18 @@ export default function FireCalculatorForm() {
<Input <Input
placeholder="e.g., 500" placeholder="e.g., 500"
type="number" type="number"
{...field} value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -372,7 +314,46 @@ export default function FireCalculatorForm() {
<Input <Input
placeholder="e.g., 30" placeholder="e.g., 30"
type="number" type="number"
{...field} value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -390,7 +371,47 @@ export default function FireCalculatorForm() {
placeholder="e.g., 7" placeholder="e.g., 7"
type="number" type="number"
step="0.1" step="0.1"
{...field} value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="inflationRate"
render={({ field }) => (
<FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -409,42 +430,46 @@ export default function FireCalculatorForm() {
<Input <Input
placeholder="e.g., 2000" placeholder="e.g., 2000"
type="number" type="number"
{...field} value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Retirement Age Slider */}
<FormField <FormField
control={form.control} control={form.control}
name="inflationRate" name="retirementAge"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel> <FormLabel>
Retirement Age: {field.value as number}
</FormLabel>
<FormControl> <FormControl>
<Input <Slider
placeholder="e.g., 2" name="retirementAge"
type="number" value={[field.value as number]}
step="0.1" min={25}
{...field} max={75}
/> step={1}
</FormControl> onValueChange={(value: number[]) => {
<FormMessage /> field.onChange(value[0]);
</FormItem> void form.handleSubmit(onSubmit)();
)} }}
/> className="py-4"
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
{...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -453,85 +478,27 @@ export default function FireCalculatorForm() {
/> />
</div> </div>
{!result && (
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
Calculate Calculate
</Button> </Button>
</form>
</Form>
</CardContent>
</Card>
{result && (
<>
<Card className="mb-8">
<CardHeader>
<CardTitle>Results</CardTitle>
</CardHeader>
<CardContent>
{result.error ? (
<p className="text-destructive">{result.error}</p>
) : (
<div className="space-y-4">
<div>
<Label>FIRE Number (Required Capital)</Label>
<p className="text-2xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</div>
<div>
<Label>Estimated Retirement Age</Label>
<p className="text-2xl font-bold">
{result.retirementAge ?? "N/A"}
</p>
</div>
{result.inflationAdjustedAllowance && (
<div>
<Label>
Monthly Allowance at Retirement (Inflation Adjusted)
</Label>
<p className="text-2xl font-bold">
{formatNumber(result.inflationAdjustedAllowance)}
</p>
</div>
)} )}
{result.retirementYears && ( {result?.yearlyData && (
<div> <Card className="rounded-md shadow-none">
<Label>Retirement Duration (Years)</Label>
<p className="text-2xl font-bold">
{result.retirementYears}
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
{result && result.yearlyData && result.yearlyData.length > 0 && (
<Card>
<CardHeader> <CardHeader>
<CardTitle>Financial Projection</CardTitle> <CardTitle>Financial Projection</CardTitle>
<CardDescription> <CardDescription>
Projected balance growth and FIRE number threshold Projected balance growth with your selected retirement age
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-2">
<ChartContainer <ChartContainer
className="h-80" className="aspect-auto h-80 w-full"
config={{ config={{}}
balance: {
label: "Balance",
color: "var(--chart-1)",
},
fireNumber: {
label: "FIRE Number",
color: "var(--chart-3)",
},
}}
> >
<AreaChart <AreaChart
data={result.yearlyData} data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }} margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
> >
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis <XAxis
@@ -542,36 +509,42 @@ export default function FireCalculatorForm() {
offset: -10, offset: -10,
}} }}
/> />
{/* Right Y axis */}
<YAxis <YAxis
yAxisId={"right"}
orientation="right"
tickFormatter={(value: number) => { tickFormatter={(value: number) => {
if (value >= 1000000) { if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`; return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) { } else if (value >= 1000) {
return `${(value / 1000).toFixed(0)}K`; return `${(value / 1000).toPrecision(3)}K`;
} else if (value <= -1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value <= -1000) {
return `${(value / 1000).toPrecision(3)}K`;
} }
return `${value}`; return value.toString();
}} }}
width={80} width={30}
stroke="var(--color-orange-500)"
tick={{}}
/> />
<ChartTooltip {/* Left Y axis */}
content={({ active, payload }) => { <YAxis
if (active && payload?.[0]?.payload) { yAxisId="left"
const data = payload[0] orientation="left"
.payload as (typeof result.yearlyData)[0]; tickFormatter={(value: number) => {
return ( if (value >= 1000000) {
<div className="bg-background border p-2 shadow-sm"> return `${(value / 1000000).toPrecision(3)}M`;
<p className="font-medium">{`Year: ${data.year} (Age: ${data.age})`}</p> } else if (value >= 1000) {
<p className="text-primary">{`Balance: ${formatNumber(data.balance)}`}</p> return `${(value / 1000).toPrecision(3)}K`;
{result.fireNumber && (
<p className="text-destructive">{`FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)}
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
} }
return null; return value.toString();
}} }}
width={30}
stroke="var(--color-red-600)"
/> />
<ChartTooltip content={tooltipRenderer} />
<defs> <defs>
<linearGradient <linearGradient
id="fillBalance" id="fillBalance"
@@ -582,12 +555,12 @@ export default function FireCalculatorForm() {
> >
<stop <stop
offset="5%" offset="5%"
stopColor="var(--chart-1)" stopColor="var(--color-orange-500)"
stopOpacity={0.8} stopOpacity={0.8}
/> />
<stop <stop
offset="95%" offset="95%"
stopColor="var(--chart-1)" stopColor="var(--color-orange-500)"
stopOpacity={0.1} stopOpacity={0.1}
/> />
</linearGradient> </linearGradient>
@@ -596,35 +569,76 @@ export default function FireCalculatorForm() {
type="monotone" type="monotone"
dataKey="balance" dataKey="balance"
name="balance" name="balance"
stroke="var(--chart-1)" stroke="var(--color-orange-500)"
fill="url(#fillBalance)" fill="url(#fillBalance)"
fillOpacity={0.4} fillOpacity={0.9}
activeDot={{ r: 6 }} activeDot={{ r: 6 }}
yAxisId={"right"}
stackId={"a"}
/>
<Area
type="step"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--color-red-600)"
fill="none"
activeDot={{ r: 6 }}
yAxisId="left"
/> />
{result.fireNumber && ( {result.fireNumber && (
<ReferenceLine <ReferenceLine
y={result.fireNumber} y={result.fireNumber}
stroke="var(--chart-3)" stroke="var(--primary)"
strokeWidth={2} strokeWidth={2}
strokeDasharray="5 5" strokeDasharray="2 1"
label={{ label={{
value: "FIRE Number", value: "FIRE Number",
position: "insideBottomRight", position: "insideBottomRight",
}} }}
yAxisId={"right"}
/>
)}
{result.fireNumber4percent && showing4percent && (
<ReferenceLine
y={result.fireNumber4percent}
stroke="var(--secondary)"
strokeWidth={1}
strokeDasharray="1 1"
label={{
value: "4%-Rule FIRE Number",
position: "insideBottomLeft",
}}
yAxisId={"right"}
/> />
)} )}
{result.retirementAge && (
<ReferenceLine <ReferenceLine
x={ x={
currentYear + irlYear +
(result.retirementAge - form.getValues().currentAge) (Number(form.getValues("retirementAge")) -
Number(form.getValues("currentAge")))
} }
stroke="var(--chart-2)" stroke="var(--primary)"
strokeWidth={2} strokeWidth={2}
label={{ label={{
value: "Retirement", value: "Retirement",
position: "insideTopRight", position: "insideTopRight",
}} }}
yAxisId={"left"}
/>
{result.retirementAge4percent && showing4percent && (
<ReferenceLine
x={
irlYear +
(result.retirementAge4percent -
Number(form.getValues("currentAge")))
}
stroke="var(--secondary)"
strokeWidth={1}
label={{
value: "4%-Rule Retirement",
position: "insideBottomLeft",
}}
yAxisId={"left"}
/> />
)} )}
</AreaChart> </AreaChart>
@@ -632,8 +646,96 @@ export default function FireCalculatorForm() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{result && (
<Button
onClick={() => setShowing4percent(!showing4percent)}
variant={showing4percent ? "secondary" : "default"}
size={"sm"}
>
{showing4percent ? "Hide" : "Show"} 4%-Rule
</Button>
)}
</form>
</Form>
</CardContent>
</Card>
{result && (
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{result.error ? (
<Card className="col-span-full">
<CardContent className="pt-6">
<p className="text-destructive">{result.error}</p>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs">
Capital at retirement
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years to enjoy your financial independence
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{Number(form.getValues("lifeExpectancy")) -
Number(form.getValues("retirementAge"))}
</p>
</CardContent>
</Card>
{showing4percent && (
<>
<Card>
<CardHeader>
<CardTitle>4%-Rule FIRE Number</CardTitle>
<CardDescription className="text-xs">
Capital needed for 4% of it to be greater than your
yearly allowance
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber4percent)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4%-Rule Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years to enjoy your financial independence if you follow
the 4% rule
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{Number(form.getValues("lifeExpectancy")) -
(result.retirementAge4percent ?? 0)}
</p>
</CardContent>
</Card>
</>
)}
</> </>
)} )}
</div> </div>
)}
</>
); );
} }

View File

@@ -0,0 +1,16 @@
export default function Footer() {
return (
<footer className="w-full py-8 text-center text-xs">
<p className="text-xs">
© {new Date().getFullYear()} InvestingFIRE. All rights reserved.{" "}
<a
href="https://schulze.network"
target="_blank"
className="text-primary hover:underline"
>
Hosting by Schulze.network
</a>
</p>
</footer>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { usePlausible } from "next-plausible";
import { useReportWebVitals } from "next/web-vitals";
interface Metric {
/**
* The name of the metric (in acronym form).
*/
name: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB";
/**
* The current value of the metric.
*/
value: number;
/**
* The rating as to whether the metric value is within the "good",
* "needs improvement", or "poor" thresholds of the metric.
*/
rating: "good" | "needs-improvement" | "poor";
/**
* The delta between the current value and the last-reported value.
* On the first report, `delta` and `value` will always be the same.
*/
delta: number;
/**
* A unique ID representing this particular metric instance. This ID can
* be used by an analytics tool to dedupe multiple values sent for the same
* metric instance, or to group multiple deltas together and calculate a
* total. It can also be used to differentiate multiple different metric
* instances sent from the same page, which can happen if the page is
* restored from the back/forward cache (in that case new metrics object
* get created).
*/
id: string;
/**
* Any performance entries relevant to the metric value calculation.
* The array may also be empty if the metric value was not based on any
* entries (e.g. a CLS value of 0 given no layout shifts).
*/
entries: PerformanceEntry[];
/**
* The type of navigation.
*
* This will be the value returned by the Navigation Timing API (or
* `undefined` if the browser doesn't support that API), with the following
* exceptions:
* - 'back-forward-cache': for pages that are restored from the bfcache.
* - 'back_forward' is renamed to 'back-forward' for consistency.
* - 'prerender': for pages that were prerendered.
* - 'restore': for pages that were discarded by the browser and then
* restored by the user.
*/
navigationType:
| "navigate"
| "reload"
| "back-forward"
| "back-forward-cache"
| "prerender"
| "restore";
}
export function WebVitals() {
const plausible = usePlausible();
useReportWebVitals((metric: Metric) => {
plausible("web-vitals", {
props: {
[metric.name]: metric.rating,
},
});
});
return <></>;
}

BIN
src/app/favicon.ico (Stored with Git LFS) Normal file

Binary file not shown.

1
src/app/icon0.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><rect width="264.58" height="264.58" fill="#fdf2e4" ry="42.08" style="-inkscape-stroke:none"/><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
src/app/icon1.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@@ -1,14 +1,17 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import PlausibleProvider from "next-plausible";
import { type Metadata } from "next"; import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import { WebVitals } from "./components/web-vitals";
export const viewport: Viewport = {
themeColor: [{ color: "oklch(0.97 0.0228 95.96)" }],
};
export const metadata: Metadata = { export const metadata: Metadata = {
title: title: "InvestingFIRE | Finance and Retirement Calculator",
"FIRE Calculator - Plan Your Financial Independence & Early Retirement",
description: description:
"Calculate your FIRE number, estimate your retirement age, and plan your path to financial independence with this comprehensive FIRE calculator.", "Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personalized projections in buttersmooth graphs.",
icons: [{ rel: "icon", url: "/favicon.ico" }],
}; };
const geist = Geist({ const geist = Geist({
@@ -21,6 +24,17 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en" className={geist.variable}> <html lang="en" className={geist.variable}>
<head>
<meta name="apple-mobile-web-app-title" content="FIRE" />
<PlausibleProvider
domain="investingfire.com"
customDomain="https://analytics.schulze.network"
selfHosted={true}
enabled={true}
trackOutboundLinks={true}
/>
</head>
<WebVitals />
<body>{children}</body> <body>{children}</body>
</html> </html>
); );

21
src/app/manifest.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "InvestingFIRE",
"short_name": "FIRE",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#fdf2e4",
"background_color": "#fdf2e4",
"display": "standalone"
}

View File

@@ -1,3 +1,4 @@
import Image from "next/image";
import FireCalculatorForm from "./components/FireCalculatorForm"; import FireCalculatorForm from "./components/FireCalculatorForm";
import { import {
Accordion, Accordion,
@@ -5,175 +6,235 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import Footer from "./components/footer";
import BackgroundPattern from "./components/BackgroundPattern";
export default function HomePage() { export default function HomePage() {
return ( return (
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-4"> <main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
<div className="container mx-auto flex flex-col items-center justify-center gap-12 px-4 py-16"> <BackgroundPattern />
<h1 className="text-primary-foreground text-5xl font-extrabold tracking-tight sm:text-[5rem]"> <div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
FIRE Calculator <div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
<Image
priority
unoptimized
src="/investingfire_logo_no-bg.svg"
alt="InvestingFIRE Logo"
width={100}
height={100}
/>
<h1 className="from-primary via-primary-foreground to-primary bg-gradient-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
InvestingFIRE
</h1> </h1>
</div>
<p className="text-primary-foreground/90 text-xl font-semibold md:text-2xl">
The #1 FIRE Calculator
</p>
<div className="mt-8 w-full max-w-2xl">
<FireCalculatorForm /> <FireCalculatorForm />
</div> </div>
</div>
{/* Added SEO Content Sections */} {/* Added SEO Content Sections */}
<div className="container mx-auto max-w-4xl px-4 py-8 text-left"> <div className="z-10 mx-auto max-w-2xl py-12 text-left">
<section className="mb-12"> <section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">What is FIRE?</h2> <h2 className="mb-4 text-3xl font-bold">
What Is FIRE? Understanding Financial Independence and Early
Retirement
</h2>
<p className="mb-4 text-lg leading-relaxed"> <p className="mb-4 text-lg leading-relaxed">
FIRE stands for &quot;Financial Independence, Retire Early.&quot; FIRE stands for{" "}
It&apos;s a movement focused on aggressive saving and investing to <strong>Financial Independence, Retire Early</strong>. It&apos;s a
build a large enough portfolio that the returns can cover living lifestyle movement built around two core ideas:
expenses indefinitely. Achieving FIRE means you are no longer
dependent on traditional employment to fund your lifestyle, giving
you the freedom to pursue passions, travel, or simply enjoy life
without the need for a regular paycheck.
</p>
<p className="text-lg leading-relaxed">
The core principle often involves saving a high percentage of income
(sometimes 50% or more) and investing it wisely, typically in
low-cost index funds. The target amount, often called the &quot;FIRE
number,&quot; is usually calculated as 25 times your desired annual
spending, based on the 4% safe withdrawal rate rule.
</p>
</section>
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">How This Calculator Works</h2>
<p className="mb-4 text-lg leading-relaxed">
This calculator helps you estimate your path to FIRE based on your
current financial situation and future projections. Here&apos;s a
breakdown of the inputs:
</p> </p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg"> <ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li> <li>
<strong>Starting Capital:</strong> The total amount you currently <strong>Aggressive saving & investing</strong>often 50%+ of
have invested. incomeso your capital grows rapidly.
</li> </li>
<li> <li>
<strong>Monthly Savings:</strong> The amount you consistently save <strong>Passive-income coverage</strong>when your investment
and invest each month. returns exceed your living expenses, you gain freedom from a
</li> traditional 9-5.
<li>
<strong>Current Age:</strong> Your current age in years.
</li>
<li>
<strong>Expected Annual Growth Rate (%):</strong> The average
annual return you expect from your investments (after fees, before
inflation).
</li>
<li>
<strong>Desired Monthly Allowance (Today&apos;s Value):</strong>{" "}
How much you want to be able to spend each month in retirement, in
today&apos;s money value.
</li>
<li>
<strong>Annual Inflation Rate (%):</strong> The expected average
rate at which the cost of living will increase.
</li>
<li>
<strong>Life Expectancy (Age):</strong> The age until which you
want your funds to last.
</li> </li>
</ul> </ul>
<p className="text-lg leading-relaxed"> <p className="text-lg leading-relaxed">
The calculator simulates your investment growth year by year, By reaching your personal <em>FIRE Number</em>the nest egg needed
factoring in monthly contributions, compound growth, and to cover your inflation-adjusted spendingyou unlock the option to
inflation&apos;s effect on your target allowance. It then determines step away from a daily paycheck and pursue passion projects, travel,
the age at which your accumulated capital is sufficient to sustain family, or anything else. This calculator helps you simulate your
your desired, inflation-adjusted monthly allowance throughout your journey, estimate how much you need, and visualize both your
expected retirement years until your specified life expectancy. It accumulation phase and your retirement withdrawals over time.
estimates your &quot;FIRE Number&quot; (the capital needed at
retirement) and the age you might reach it.
</p> </p>
</section> </section>
<section className="mb-12"> <section className="mb-12">
<h2 className="mb-4 text-3xl font-bold"> <h2 className="mb-4 text-3xl font-bold">
Frequently Asked Questions (FAQ) How This FIRE Calculator Provides Investing Insights
</h2>
<p className="mb-4 text-lg leading-relaxed">
Our interactive tool goes beyond a simple 25x annual spending
rule. It runs a <strong>year-by-year simulation</strong> of your
portfolio, combining:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Starting Capital</strong>your current invested balance
</li>
<li>
<strong>Monthly Savings</strong>ongoing contributions to your
portfolio
</li>
<li>
<strong>Expected Annual Growth Rate (CAGR)</strong>compounding
returns before inflation
</li>
<li>
<strong>Annual Inflation Rate</strong>to inflate your target
withdrawal each year
</li>
<li>
<strong>Desired Monthly Allowance</strong>today&apos;s-value
spending goal
</li>
<li>
<strong>Retirement Age & Life Expectancy</strong>defines your
accumulation horizon and payout period
</li>
</ul>
<p className="text-lg leading-relaxed">Key features:</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Real-time calculation</strong>as you tweak any input,
your FIRE Number and chart update instantly.
</li>
<li>
<strong>Interactive chart</strong> with area plots for both{" "}
<em>portfolio balance</em> and{" "}
<em>inflation-adjusted allowance</em>, plus reference lines
showing your retirement date and required FIRE Number.
</li>
<li>
<strong>Custom simulation</strong>switches from accumulation
(adding savings) to retirement (withdrawing allowance),
compounding each year based on your growth rate.
</li>
</ul>
<p className="text-lg leading-relaxed">
With this level of granularity, you can confidently experiment with
savings rate, target retirement age, and investment assumptions to
discover how small tweaks speed up or delay your path to financial
independence.
</p>
</section>
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">
FIRE & Investing Frequently Asked Questions (FAQ)
</h2> </h2>
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1"> <AccordionItem value="item-1">
<AccordionTrigger className="text-xl font-semibold"> <AccordionTrigger className="text-xl font-semibold">
What is the 4% rule? What methodology does this calculator use?
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed"> <AccordionContent className="text-lg leading-relaxed">
The 4% rule is a guideline suggesting that you can safely We run a multi-year projection in two phases:
withdraw 4% of your investment portfolio&apos;s value in your <ol className="ml-6 list-decimal space-y-1">
first year of retirement, and then adjust that amount for <li>
inflation each subsequent year, with a high probability of your <strong>Accumulation:</strong> Your balance grows by CAGR
money lasting for at least 30 years. This calculator uses a more and you add monthly savings.
dynamic simulation based on your life expectancy but is related </li>
to this concept. <li>
<strong>Retirement:</strong> The balance continues
compounding, but you withdraw an inflation-adjusted monthly
allowance.
</li>
</ol>
The result: a precise estimate of the capital you&apos;ll have
at retirement (your FIRE Number) and how long it will last
until your chosen life expectancy.
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-2"> <AccordionItem value="item-2">
<AccordionTrigger className="text-xl font-semibold"> <AccordionTrigger className="text-xl font-semibold">
Is the Expected Growth Rate realistic? Why isn&apos;t this just the 4% rule?
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed"> <AccordionContent className="text-lg leading-relaxed">
Historically, diversified stock market investments have returned The 4% rule is a useful starting point (25× annual spending),
around 7-10% annually over the long term, before inflation. A but it assumes a fixed withdrawal rate with inflation
rate of 7% (after fees) is often used as a reasonable estimate, adjustments and doesn&apos;t model ongoing savings or dynamic
but past performance doesn&apos;t guarantee future results. market returns. Our calculator simulates each year&apos;s
It&apos;s crucial to choose a rate you feel comfortable with and growth, contributions, and inflation-indexed withdrawals to give
understand the associated risks. you a tailored picture.
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-3"> <AccordionItem value="item-3">
<AccordionTrigger className="text-xl font-semibold"> <AccordionTrigger className="text-xl font-semibold">
How does inflation impact my FIRE number? How do I choose a realistic growth rate?
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed"> <AccordionContent className="text-lg leading-relaxed">
Inflation erodes the purchasing power of money over time. Your Historically, a diversified portfolio of equities and bonds has
desired monthly allowance needs to increase each year just to returned around 7-10% per year before inflation. We recommend
maintain the same standard of living. This calculator accounts starting around 6-8% (net of fees), then running what-if
for this by adjusting your target allowance upwards based on the scenarios5% on the conservative side, 10% on the aggressive
inflation rate you provide, ensuring the calculated FIRE number sideto see how they affect your timeline.
supports your desired lifestyle in future dollars.
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-4"> <AccordionItem value="item-4">
<AccordionTrigger className="text-xl font-semibold"> <AccordionTrigger className="text-xl font-semibold">
Can I really retire early? How does inflation factor into my FIRE Number?
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed"> <AccordionContent className="text-lg leading-relaxed">
Retiring significantly earlier than traditional retirement age Cost of living rises. To maintain today&apos;s lifestyle, your
is possible but requires discipline, a high savings rate, and monthly allowance must grow each year by your inflation rate.
consistent investment growth. The feasibility depends heavily on This calculator automatically inflates your desired monthly
your income, expenses, savings habits, and investment returns. spending and subtracts it from your portfolio during retirement,
Use this calculator as a tool for planning and motivation, but ensuring your FIRE Number keeps pace with rising expenses.
remember it provides estimates based on your inputs.
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-5"> <AccordionItem value="item-5">
<AccordionTrigger className="text-xl font-semibold"> <AccordionTrigger className="text-xl font-semibold">
What does FIRE stand for? Can I really retire early with FIRE?
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed"> <AccordionContent className="text-lg leading-relaxed">
FIRE stands for Financial Independence, Retire Early. It Early retirement is achievable with disciplined saving, smart
represents a lifestyle movement aimed at maximizing your savings investing, and realistic assumptions. This tool helps you set
rate through increased income and/or decreased expenses to targets, visualize outcomes, and adjust inputsso you can build
achieve financial independence and retire much earlier than confidence in your plan and make informed trade-offs between
traditional retirement age. lifestyle, risk, and timeline.
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-6"> <AccordionItem value="item-6">
<AccordionTrigger className="text-xl font-semibold"> <AccordionTrigger className="text-xl font-semibold">
How much should I save each month? How should I use this calculator effectively?
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed"> <AccordionContent className="text-lg leading-relaxed">
FIRE enthusiasts typically aim to save 50-70% of their income. <ul className="ml-6 list-disc space-y-1">
The more you can save, the faster you&apos;ll reach your FIRE <li>
goal. However, the right amount depends on your income, Start with your actual numbers (capital, savings, age).
lifestyle, and target retirement age. Use the calculator to </li>
experiment with different monthly savings amounts to see their <li>
impact on your retirement timeline. Set conservative - mid - aggressive growth rates to bound
possibilities.
</li>
<li>
Slide your retirement age to explore early vs.
traditional scenarios.
</li>
<li>
Review the chartespecially the reference linesto see when
you hit FI and how withdrawals impact your balance.
</li>
<li>
Experiment with higher savings rates or lower target
spending to accelerate your path.
</li>
</ul>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
@@ -182,28 +243,35 @@ export default function HomePage() {
{/* Optional: Add a section for relevant resources/links here */} {/* Optional: Add a section for relevant resources/links here */}
<section className="mb-12"> <section className="mb-12">
<h2 className="mb-4 text-3xl font-bold"> <h2 className="mb-4 text-3xl font-bold">
Further Reading & Resources FIRE Journey & Investing Resources
</h2> </h2>
<p className="mb-6 text-lg leading-relaxed"> <p className="mb-6 text-lg leading-relaxed">
Want to learn more about FIRE and continue your journey to financial Ready to deepen your knowledge and build a bullet-proof plan? Below
independence? Here are some valuable resources to explore: are some of our favorite blogs, books, tools, and communities for
financial independence and smart investing.
</p> </p>
<div className="bg-secondary/20 my-8 rounded-md p-4 text-lg"> <div className="bg-foreground my-8 rounded-md p-4 text-lg">
<p className="font-semibold">Getting Started with FIRE:</p> <p className="font-semibold">Getting Started with FIRE:</p>
<ol className="ml-6 list-decimal space-y-1"> <ol className="ml-6 list-decimal space-y-1">
<li> <li>
Read foundational content like Mr. Money Mustache&apos;s simple Run your first projection above to find your target FIRE Number.
math article </li>
<li>Identify areas to boost savings or reduce expenses.</li>
<li>
Study index-fund strategies and low-cost investing advice.
</li> </li>
<li> <li>
Calculate your personal numbers using this and other FIRE Join{" "}
calculators <a
href="https://www.reddit.com/r/Fire/"
target="_blank"
className="text-primary hover:underline"
>
supportive communities like r/Fire
</a>{" "}
to learn from real journeys.
</li> </li>
<li>
Join communities like r/Fire to ask questions and find support
</li>
<li>Explore books and podcasts to deepen your understanding</li>
</ol> </ol>
</div> </div>
@@ -213,70 +281,70 @@ export default function HomePage() {
<ul className="ml-6 list-disc space-y-2 text-lg"> <ul className="ml-6 list-disc space-y-2 text-lg">
<li> <li>
<a <a
href="https://www.mrmoneymustache.com/2012/01/13/the-shockingly-simple-math-behind-early-retirement/" href="https://www.mrmoneymustache.com/"
target="_blank" target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
Mr. Money Mustache - The Shockingly Simple Math Behind Early Mr. Money Mustache
Retirement </a>{" "}
</a> - Hardcore frugality & early retirement success stories.
</li>
<li>
<a
href="https://www.playingwithfire.co/resources"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Playing With FIRE - Comprehensive Resources
</a>
</li>
<li>
<a
href="https://www.reddit.com/r/Fire/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
r/Fire Reddit Community
</a>
</li>
</ul>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">Books & Learning</h3>
<ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://www.amazon.com/Your-Money-Life-Transforming-Relationship/dp/0143115766"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Your Money or Your Life - Vicki Robin & Joe Dominguez
</a>
</li> </li>
<li> <li>
<a <a
href="https://www.playingwithfire.co/" href="https://www.playingwithfire.co/"
target="_blank" target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
Playing With FIRE Documentary Playing With FIRE
</a> </a>{" "}
- Community resources & real-life case studies.
</li>
<li>
<a
href="https://www.reddit.com/r/Fire/"
target="_blank"
className="text-primary hover:underline"
>
r/Fire
</a>{" "}
- Active forum for questions, tips, and support.
</li>
</ul>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">Books & Podcasts</h3>
<ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://www.amazon.com/Your-Money-Life-Transforming-Relationship/dp/0143115766"
target="_blank"
className="text-primary hover:underline"
>
Your Money or Your Life
</a>{" "}
- The classic guide to aligning money with values.
</li>
<li>
<a
href="https://podcasts.apple.com/us/podcast/biggerpockets-money-podcast/id1330225136"
target="_blank"
className="text-primary hover:underline"
>
BiggerPockets Money Podcast
</a>{" "}
- Interviews on FIRE strategies and wealth building.
</li> </li>
<li> <li>
<a <a
href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292" href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292"
target="_blank" target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
BiggerPockets Money Podcast - FIRE Calculators InvestingFIRE Calculator Demo
</a> </a>{" "}
- Deep dive on how interactive projections can guide your
plan.
</li> </li>
</ul> </ul>
</div> </div>
@@ -286,63 +354,43 @@ export default function HomePage() {
Additional Calculators & Tools Additional Calculators & Tools
</h3> </h3>
<ul className="ml-6 list-disc space-y-2 text-lg"> <ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://ghostfol.io"
target="_blank"
className="text-primary hover:underline"
>
Ghostfolio
</a>{" "}
- Wealth management application for individuals.
</li>
<li> <li>
<a <a
href="https://walletburst.com/tools/coast-fire-calculator/" href="https://walletburst.com/tools/coast-fire-calculator/"
target="_blank" target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
Coast FIRE Calculator - For those considering a partial Coast FIRE Calculator
early retirement </a>{" "}
</a> - When you max out early contributions but let compounding
do the rest.
</li> </li>
<li> <li>
<a <a
href="https://www.empower.com/retirement-calculator" href="https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator"
target="_blank" target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
Empower Retirement Planner - Free portfolio analysis and net Compound Interest Calculator
worth tracking </a>{" "}
</a> - Explore the power of growth rates in isolation.
</li>
</ul>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">
Recent Articles & Trends
</h3>
<ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://www.businessinsider.com/retiring-tech-early-coast-fire-make-me-millionaire-2025-4"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Coast FIRE: Retiring in your 30s while becoming a
millionaire by 60
</a>
</li>
<li>
<a
href="https://www.businessinsider.com/financial-independence-retire-early-saving-loneliness-retreat-bali-making-friends-2025-2"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
The Social Side of FIRE: Finding Community in Financial
Independence
</a>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<Footer />
</main> </main>
); );
} }

11
src/app/robots.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "https://investingfire.com/sitemap.xml",
};
}

13
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,13 @@
import { BASE_URL } from "@/lib/constants";
import { type MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
];
}

View File

@@ -44,7 +44,7 @@ function AccordionTrigger({
{...props} {...props}
> >
{children} {children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> <ChevronDownIcon className="text-primary-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
); );

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
"use client"; "use client";
import * as React from "react"; import * as React from "react";
@@ -8,15 +9,16 @@ import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const; const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = { export type ChartConfig = Record<
[k in string]: { string,
{
label?: React.ReactNode; label?: React.ReactNode;
icon?: React.ComponentType; icon?: React.ComponentType;
} & ( } & (
| { color?: string; theme?: never } | { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> } | { color?: never; theme: Record<keyof typeof THEMES, string> }
); )
}; >;
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig; config: ChartConfig;
@@ -47,7 +49,7 @@ function ChartContainer({
>["children"]; >["children"];
}) { }) {
const uniqueId = React.useId(); const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
@@ -71,7 +73,7 @@ function ChartContainer({
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color, ([, config]) => config.theme ?? config.color,
); );
if (!colorConfig.length) { if (!colorConfig.length) {
@@ -88,7 +90,7 @@ ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color; itemConfig.color;
return color ? ` --color-${key}: ${color};` : null; return color ? ` --color-${key}: ${color};` : null;
}) })
@@ -134,11 +136,11 @@ function ChartTooltipContent({
} }
const [item] = payload; const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`; const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = const value =
!labelKey && typeof label === "string" !labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label ? (config[label]?.label ?? label)
: itemConfig?.label; : itemConfig?.label;
if (labelFormatter) { if (labelFormatter) {
@@ -180,9 +182,11 @@ function ChartTooltipContent({
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{payload.map((item, index) => { {payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`; const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color; const indicatorColor: string | undefined =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
color ?? item.payload.fill ?? item.color;
return ( return (
<div <div
@@ -193,6 +197,7 @@ function ChartTooltipContent({
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {formatter && item?.value !== undefined && item.name ? (
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
formatter(item.value, item.name, item, index, item.payload) formatter(item.value, item.name, item, index, item.payload)
) : ( ) : (
<> <>
@@ -229,7 +234,7 @@ function ChartTooltipContent({
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null} {nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{itemConfig?.label || item.name} {itemConfig?.label ?? item.name}
</span> </span>
</div> </div>
{item.value && ( {item.value && (
@@ -276,7 +281,8 @@ function ChartLegendContent({
)} )}
> >
{payload.map((item) => { {payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`; // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key);
return ( return (
@@ -338,9 +344,7 @@ function getPayloadConfigFromPayload(
] as string; ] as string;
} }
return configLabelKey in config return configLabelKey in config ? config[configLabelKey] : config[key];
? config[configLabelKey]
: config[key as keyof typeof config];
} }
export { export {

View File

@@ -0,0 +1,185 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

40
src/env.js Normal file
View File

@@ -0,0 +1,40 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

1
src/lib/constants.ts Normal file
View File

@@ -0,0 +1 @@
export const BASE_URL = "https://investingfire.com/";