Compare commits
180 Commits
64669e5f58
...
extra-seo-
Author | SHA1 | Date | |
---|---|---|---|
dffe080eef | |||
2740e57dd0 | |||
e45c437015 | |||
033088185c | |||
9aa7a7e4a2 | |||
02c87ec8bf | |||
061942aa8e | |||
5e4aca4ec2 | |||
2451c43e73 | |||
bf75fe7b2b | |||
375e93ce59 | |||
832e2cdd3d | |||
16d4dd53a1 | |||
1936fdff56 | |||
4dcbdc4366 | |||
418e1fcf52 | |||
2585e6fca9 | |||
031e5bb50e | |||
65711a7ad6 | |||
1ee037e5cc | |||
fa35db17e0 | |||
de7507323e | |||
9f1f4e1ba6 | |||
c0a1ec0ec6 | |||
1ca5545db7 | |||
bd914b05dd | |||
fc452ebd4c | |||
201c1ee523 | |||
1c1b842a15 | |||
aba4e4a7f6 | |||
4dcd24f1fd | |||
440b759daa | |||
9a54bdf93f | |||
cabcbbb84a | |||
d815cab9d8 | |||
be6a875999 | |||
31c6c7106f | |||
93c1320651 | |||
b875d65fdc | |||
26a305a96b | |||
fb32bd381c | |||
c45cad8e96 | |||
86ee8c6b32 | |||
f6dd7c3012 | |||
e34fc5dffc | |||
57be648512 | |||
c131fba360 | |||
9803c3f33d | |||
7f7a8d8728 | |||
a2184c0ef2 | |||
198a8c8c45 | |||
d87fba0ca5 | |||
0cbc745798 | |||
e59620f619 | |||
52eaa7bd4f | |||
614d1a83cc | |||
d29d4e54ef | |||
c79f504c24 | |||
aa0c90e70b | |||
b0a1512911 | |||
f34cbabff2 | |||
1b58443e69 | |||
5fdeae83c6 | |||
fb62ae2011 | |||
55c8b7c079 | |||
e39ddfd7a6 | |||
990ef286c8 | |||
dc80ce98e5 | |||
f1b29f7c5f | |||
e4416ba3df | |||
3f0584ab51 | |||
b657c589e0 | |||
c008289e09 | |||
3cee1fff9e | |||
07de9a0062 | |||
00108ab629 | |||
d790a8bb3b | |||
24e6a4ef95 | |||
5ddd9cc58f | |||
adcdce67cb | |||
51c4bd7316 | |||
bff9c98db1 | |||
bedaa2090f | |||
71df024aa9 | |||
228a0bdaaf | |||
a4a3ed403b | |||
863d4f3268 | |||
9719383056 | |||
6c77a71a79 | |||
b54c8e3d2b | |||
1cffa649d6 | |||
7c38ed2a60 | |||
7a69e9ff45 | |||
e71f28d8c2 | |||
d26a3252cc | |||
2a7182625b | |||
d7cbcf3707 | |||
374cb17eeb | |||
d5f8c84f13 | |||
d116e01f8d | |||
a297b8a4aa | |||
acb4e5bc51 | |||
606512fad8 | |||
a5d07b8b3e | |||
0a7a51ef64 | |||
2d5f9b051d | |||
d83ef08f7c | |||
7362a6545b | |||
553f5155c7 | |||
44a98fe001 | |||
0736907b84 | |||
4f4a74dc72 | |||
6387e96bf7 | |||
96f95e7b08 | |||
87ea9e1ecc | |||
51add59741 | |||
29d42c0f22 | |||
e36c062b9c | |||
8273fce712 | |||
820d7f4883 | |||
6d487f8792 | |||
f4ab9d3745 | |||
e1da910a25 | |||
225f9ef1ab | |||
c071b9c052 | |||
11e1e31ac5 | |||
8ac784f49b | |||
0b0e6c1c9a | |||
c3168220fe | |||
229f2d7b56 | |||
a86eddda31 | |||
0a96a94cf3 | |||
0e6086a597 | |||
dafdd0d154 | |||
b9b52377e0 | |||
63fe8e5999 | |||
fc18e414cb | |||
a3eda1f0db | |||
2b2b5784d1 | |||
32ef797bf6 | |||
02761928a5 | |||
763c8b590d | |||
e616e8f261 | |||
a0c5665941 | |||
d24c8b910a | |||
8f7ebf7b5a | |||
7c05542d5e | |||
32a0b7a0ac | |||
c1e57577cd | |||
d2735a7020 | |||
4348e4bdf3 | |||
4026924e06 | |||
bf9098e3e5 | |||
1082dc3b69 | |||
670ed01ede | |||
2bc1d42cf7 | |||
23e03c9a32 | |||
fdd923cfbc | |||
6a6557c3bf | |||
5544c2f69f | |||
24547c3087 | |||
6a6f0ee9a5 | |||
439b7c395c | |||
d761ac0348 | |||
bc08871f86 | |||
a032a132e4 | |||
54ed15ff25 | |||
cdb67cae95 | |||
5888b46b25 | |||
26ceef1740 | |||
857f1a242b | |||
9531fcea99 | |||
2be1a6b947 | |||
dd40e92179 | |||
23c2c0ea21 | |||
3660cc0310 | |||
6018239c43 | |||
97fa489d6c | |||
a1e9b667f3 | |||
ab5eb23238 |
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||
# when you add new variables to `.env`.
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||
# ".env" and populate it with your secrets.
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.js"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Example:
|
||||
# SERVERVAR="foo"
|
||||
# NEXT_PUBLIC_CLIENTVAR="bar"
|
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.ico filter=lfs diff=lfs merge=lfs -text
|
31
.gitea/workflows/check.yml
Normal file
31
.gitea/workflows/check.yml
Normal 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@a7487c7e89a18df4991f7f222e4898a00d66ddda # 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
|
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# database
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
db.sqlite
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
next-env.d.ts
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# idea files
|
||||
.idea
|
56
LICENSE
56
LICENSE
@@ -48,7 +48,7 @@ To “convey” a work means any kind of propagation that enables other parties
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
@@ -61,47 +61,47 @@ The Corresponding Source need not include anything that users can regenerate aut
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
@@ -116,7 +116,7 @@ The requirement to provide Installation Information does not include a requireme
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
@@ -141,7 +141,7 @@ If you add terms to a covered work in accord with this section, you must place,
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
@@ -150,17 +150,17 @@ Moreover, your license from a particular copyright holder is reinstated permanen
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
@@ -177,13 +177,13 @@ A patent license is “discriminatory” if it does not include within the scope
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
@@ -192,13 +192,13 @@ If the Program specifies that a proxy can decide which future versions of the GN
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
|
109
README.md
109
README.md
@@ -1,3 +1,108 @@
|
||||
# fire
|
||||

|
||||
|
||||
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 project’s 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 you’ve already invested
|
||||
- **Monthly Savings** — What you’ll 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, today’s 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.**
|
||||
|
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
48
eslint.config.js
Normal file
48
eslint.config.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [".next"],
|
||||
},
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
10
next.config.js
Normal file
10
next.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
import "./src/env.js";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {};
|
||||
|
||||
export default config;
|
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "fire",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev --turbopack",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint:fix": "next lint --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.8",
|
||||
"@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",
|
||||
"@t3-oss/env-nextjs": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^15.4.1",
|
||||
"next-plausible": "^3.12.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@tailwindcss/postcss": "4.1.11",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-config-next": "15.4.4",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "4.1.11",
|
||||
"tw-animate-css": "1.3.6",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.39.0"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0"
|
||||
}
|
5184
pnpm-lock.yaml
generated
Normal file
5184
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
ignoredBuiltDependencies:
|
||||
- unrs-resolver
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- sharp
|
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
4
prettier.config.js
Normal file
4
prettier.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
export default {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
1
public/investingfire_logo_no-bg.svg
Normal file
1
public/investingfire_logo_no-bg.svg
Normal 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
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
BIN
public/web-app-manifest-512x512.png
(Stored with Git LFS)
Normal file
Binary file not shown.
1
public/wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk.txt
Normal file
1
public/wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk.txt
Normal file
@@ -0,0 +1 @@
|
||||
wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk
|
11
renovate.json
Normal file
11
renovate.json
Normal 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
BIN
src/app/apple-icon.png
(Stored with Git LFS)
Normal file
Binary file not shown.
223
src/app/calculators/4-percent-rule/FourPercentRuleCalculator.tsx
Normal file
223
src/app/calculators/4-percent-rule/FourPercentRuleCalculator.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
||||
export default function FourPercentRuleCalculator() {
|
||||
const [annualExpenses, setAnnualExpenses] = useState(50000);
|
||||
const [portfolioValue, setPortfolioValue] = useState(0);
|
||||
const [withdrawalRate, setWithdrawalRate] = useState(4);
|
||||
|
||||
// Calculate FIRE number based on withdrawal rate
|
||||
const fireNumber = Math.round(annualExpenses / (withdrawalRate / 100));
|
||||
|
||||
// Calculate safe withdrawal amount from portfolio
|
||||
const safeWithdrawal = Math.round(portfolioValue * (withdrawalRate / 100));
|
||||
|
||||
// Calculate years to FIRE if saving
|
||||
const monthlySavings = 2000; // Default for demo
|
||||
const growthRate = 0.07; // 7% annual growth
|
||||
const yearsToFire =
|
||||
portfolioValue < fireNumber
|
||||
? Math.log(
|
||||
(fireNumber + (monthlySavings * 12) / growthRate) /
|
||||
(portfolioValue + (monthlySavings * 12) / growthRate),
|
||||
) / Math.log(1 + growthRate)
|
||||
: 0;
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Input Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calculate Your FIRE Number</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your annual expenses and current portfolio value to see your
|
||||
path to financial independence
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="annual-expenses">Annual Expenses</Label>
|
||||
<Input
|
||||
id="annual-expenses"
|
||||
type="number"
|
||||
value={annualExpenses}
|
||||
onChange={(e) => setAnnualExpenses(Number(e.target.value))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your yearly spending in retirement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio-value">Current Portfolio Value</Label>
|
||||
<Input
|
||||
id="portfolio-value"
|
||||
type="number"
|
||||
value={portfolioValue}
|
||||
onChange={(e) => setPortfolioValue(Number(e.target.value))}
|
||||
min={0}
|
||||
step={10000}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your current invested assets
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="withdrawal-rate">Withdrawal Rate</Label>
|
||||
<span className="text-sm font-medium">{withdrawalRate}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="withdrawal-rate"
|
||||
min={3}
|
||||
max={5}
|
||||
step={0.1}
|
||||
value={[withdrawalRate]}
|
||||
onValueChange={(value) => setWithdrawalRate(value[0] ?? 4)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The classic 4% rule suggests 4%, but adjust based on your risk
|
||||
tolerance
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Your FIRE Number</CardTitle>
|
||||
<CardDescription>
|
||||
Portfolio needed for {withdrawalRate}% withdrawal rate
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-primary text-4xl font-bold">
|
||||
{formatCurrency(fireNumber)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
This is {Math.round(fireNumber / annualExpenses)}× your annual
|
||||
expenses
|
||||
</p>
|
||||
{portfolioValue > 0 && portfolioValue < fireNumber && (
|
||||
<div className="bg-foreground/5 mt-4 rounded-lg p-3">
|
||||
<p className="text-sm font-medium">Progress to FIRE</p>
|
||||
<div className="mt-2 h-2 rounded-full bg-gray-200">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min((portfolioValue / fireNumber) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{Math.round((portfolioValue / fireNumber) * 100)}% complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Safe Annual Withdrawal</CardTitle>
|
||||
<CardDescription>From your current portfolio</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-primary text-4xl font-bold">
|
||||
{formatCurrency(safeWithdrawal)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Monthly: {formatCurrency(safeWithdrawal / 12)}
|
||||
</p>
|
||||
{safeWithdrawal > 0 && safeWithdrawal < annualExpenses && (
|
||||
<div className="mt-4 rounded-lg bg-orange-100 p-3 dark:bg-orange-900/20">
|
||||
<p className="text-sm text-orange-900 dark:text-orange-100">
|
||||
⚠️ Your safe withdrawal ({formatCurrency(safeWithdrawal)}) is
|
||||
less than your annual expenses (
|
||||
{formatCurrency(annualExpenses)})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{safeWithdrawal >= annualExpenses && (
|
||||
<div className="mt-4 rounded-lg bg-green-100 p-3 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-900 dark:text-green-100">
|
||||
✓ Congratulations! You've reached FIRE
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Additional Insights */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Insights</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">Gap to FIRE</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(Math.max(fireNumber - portfolioValue, 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">Monthly Target</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(annualExpenses / 12)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">25× Rule Result</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(annualExpenses * 25)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Educational Note */}
|
||||
<Card className="border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/20">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm">
|
||||
<strong>💡 Pro Tip:</strong> The 4% rule is based on a 30-year
|
||||
retirement. For early retirees planning 40-50+ years, consider using
|
||||
3.5% or even 3% for added safety. Remember to account for taxes,
|
||||
healthcare costs, and inflation in your planning.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
429
src/app/calculators/4-percent-rule/page.tsx
Normal file
429
src/app/calculators/4-percent-rule/page.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import BackgroundPattern from "@/app/components/BackgroundPattern";
|
||||
import Footer from "@/app/components/footer";
|
||||
import FourPercentRuleCalculator from "./FourPercentRuleCalculator";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "4% Rule Calculator - Safe Withdrawal Rate for FIRE | InvestingFIRE",
|
||||
description:
|
||||
"Calculate your safe withdrawal rate using the 4% rule. Determine how much you need to retire early and how long your retirement savings will last. Free FIRE calculator with real-time results.",
|
||||
keywords:
|
||||
"4 percent rule calculator, safe withdrawal rate calculator, 4% rule retirement, FIRE calculator, retirement withdrawal calculator, Trinity Study calculator",
|
||||
openGraph: {
|
||||
title: "4% Rule Calculator - Safe Withdrawal Rate Calculator",
|
||||
description:
|
||||
"Free 4% rule calculator to determine your safe withdrawal rate and retirement portfolio size. Based on the Trinity Study for FIRE planning.",
|
||||
type: "website",
|
||||
url: "https://investingfire.com/calculators/4-percent-rule",
|
||||
},
|
||||
};
|
||||
|
||||
export default function FourPercentRulePage() {
|
||||
const faqData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "What is the 4% rule?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "The 4% rule is a retirement planning guideline that suggests you can safely withdraw 4% of your retirement portfolio in the first year, then adjust that amount for inflation each subsequent year, with a high probability of not running out of money over 30 years.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How accurate is the 4% rule?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "The 4% rule, based on the Trinity Study, showed a 95% success rate for a 30-year retirement with a 50/50 stock/bond portfolio. However, it's based on historical U.S. market data and may need adjustment for longer retirements, different asset allocations, or varying market conditions.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Is 4% too conservative or too aggressive?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "It depends on your situation. For early retirees with 40-50+ year horizons, 4% might be too aggressive. Some prefer 3-3.5%. Conversely, flexible spenders who can reduce withdrawals in down markets might safely use 4.5-5%. Personal factors like other income sources and spending flexibility matter.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How do I calculate my FIRE number using the 4% rule?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Simply multiply your annual expenses by 25. For example, if you need $40,000 per year, your FIRE number is $1,000,000 ($40,000 × 25). This gives you a portfolio where 4% equals your annual spending needs.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: "https://investingfire.com",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Calculators",
|
||||
item: "https://investingfire.com/calculators",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: "4% Rule Calculator",
|
||||
item: "https://investingfire.com/calculators/4-percent-rule",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
|
||||
<BackgroundPattern />
|
||||
|
||||
{/* Header */}
|
||||
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-4 transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Image
|
||||
priority
|
||||
unoptimized
|
||||
src="/investingfire_logo_no-bg.svg"
|
||||
alt="InvestingFIRE Logo"
|
||||
width={60}
|
||||
height={60}
|
||||
/>
|
||||
<span className="text-2xl font-bold">InvestingFIRE</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="from-primary via-primary-foreground to-primary mt-8 bg-gradient-to-r bg-clip-text text-4xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[4rem]">
|
||||
4% Rule Calculator
|
||||
</h1>
|
||||
<p className="text-primary-foreground/90 max-w-2xl text-xl font-semibold md:text-2xl">
|
||||
Calculate Your Safe Withdrawal Rate & FIRE Number Using the Trinity
|
||||
Study Method
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb Schema */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbData) }}
|
||||
/>
|
||||
|
||||
{/* Calculator */}
|
||||
<div className="z-10 mt-8 w-full max-w-4xl">
|
||||
<FourPercentRuleCalculator />
|
||||
</div>
|
||||
|
||||
{/* SEO Content */}
|
||||
<div className="z-10 mx-auto max-w-4xl px-4 py-12 text-left">
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Understanding the 4% Rule for Safe Retirement Withdrawals
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
The <strong>4% rule</strong> is one of the most widely recognized
|
||||
guidelines in retirement planning, particularly within the FIRE
|
||||
(Financial Independence, Retire Early) community. This rule of thumb
|
||||
suggests you can withdraw 4% of your retirement portfolio in the
|
||||
first year of retirement, then adjust that dollar amount for
|
||||
inflation each subsequent year, with a high probability of not
|
||||
depleting your savings over a 30-year retirement period.
|
||||
</p>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Originating from the groundbreaking <strong>Trinity Study</strong>{" "}
|
||||
(1998), which analyzed historical market data from 1926-1995, the 4%
|
||||
rule demonstrated a 95% success rate for mixed portfolios of stocks
|
||||
and bonds over 30-year periods. This simple yet powerful concept
|
||||
revolutionized retirement planning by providing a clear target:
|
||||
accumulate 25 times your annual expenses to achieve financial
|
||||
independence.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
How the 4% Rule Calculator Works
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Our 4% rule calculator helps you determine two critical numbers for
|
||||
your retirement planning:
|
||||
</p>
|
||||
<ol className="mb-6 ml-6 list-decimal space-y-3 text-lg">
|
||||
<li>
|
||||
<strong>Your FIRE Number</strong> - The total portfolio size
|
||||
needed to support your desired lifestyle using the 4% withdrawal
|
||||
rate
|
||||
</li>
|
||||
<li>
|
||||
<strong>Safe Annual Withdrawal</strong> - How much you can
|
||||
withdraw from a given portfolio size while maintaining the 4% rule
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="bg-foreground/10 mb-6 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Quick 4% Rule Formula:
|
||||
</h3>
|
||||
<p className="mb-2 text-lg">
|
||||
<strong>FIRE Number = Annual Expenses × 25</strong>
|
||||
</p>
|
||||
<p className="text-lg">
|
||||
<strong>Safe Annual Withdrawal = Portfolio Value × 0.04</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
For example, if you need $50,000 per year to cover your expenses,
|
||||
your FIRE number would be $1,250,000 ($50,000 × 25). Conversely, if
|
||||
you have a $2,000,000 portfolio, you could safely withdraw $80,000
|
||||
in the first year (2,000,000 × 0.04).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Adjusting the 4% Rule for Your Situation
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
While the 4% rule provides an excellent starting point, many
|
||||
financial experts suggest adjustments based on individual
|
||||
circumstances:
|
||||
</p>
|
||||
|
||||
<div className="mb-6 grid gap-6 md:grid-cols-2">
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
More Conservative Approaches
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>3.5% Rule</strong> - For early retirees with 40-50+
|
||||
year horizons
|
||||
</li>
|
||||
<li>
|
||||
<strong>3% Rule</strong> - Ultra-conservative for maximum
|
||||
safety
|
||||
</li>
|
||||
<li>
|
||||
<strong>Dynamic Withdrawals</strong> - Adjust based on market
|
||||
performance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Factors That May Allow Higher Withdrawals
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>Flexible Spending</strong> - Ability to reduce
|
||||
expenses in down markets
|
||||
</li>
|
||||
<li>
|
||||
<strong>Part-time Income</strong> - Earning some money in
|
||||
retirement
|
||||
</li>
|
||||
<li>
|
||||
<strong>Social Security/Pensions</strong> - Additional income
|
||||
sources later
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
4% Rule vs. Other FIRE Strategies
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
The 4% rule is just one approach to achieving financial
|
||||
independence. Here's how it compares to other popular FIRE
|
||||
strategies:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Traditional FIRE (4% Rule)
|
||||
</h3>
|
||||
<p>
|
||||
Target: 25× annual expenses | Best for: Balanced lifestyle with
|
||||
moderate spending
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Lean FIRE (3-3.5% Rule)
|
||||
</h3>
|
||||
<p>
|
||||
Target: 28-33× annual expenses | Best for: Minimalist lifestyle,
|
||||
maximum safety
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Fat FIRE (4-5% Rule)
|
||||
</h3>
|
||||
<p>
|
||||
Target: 20-25× annual expenses | Best for: Luxurious retirement
|
||||
with higher spending
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">Coast FIRE</h3>
|
||||
<p>
|
||||
Let investments grow while covering expenses with part-time work
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-lg">
|
||||
Want to explore these strategies in detail? Try our{" "}
|
||||
<Link
|
||||
href="/"
|
||||
className="text-primary font-semibold hover:underline"
|
||||
>
|
||||
comprehensive FIRE calculator
|
||||
</Link>{" "}
|
||||
for a full retirement simulation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="mb-12">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
||||
/>
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
4% Rule Frequently Asked Questions
|
||||
</h2>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What is the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
The 4% rule is a retirement planning guideline that suggests you
|
||||
can safely withdraw 4% of your retirement portfolio in the first
|
||||
year, then adjust that amount for inflation each subsequent
|
||||
year, with a high probability of not running out of money over
|
||||
30 years.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How accurate is the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
The 4% rule, based on the Trinity Study, showed a 95% success
|
||||
rate for a 30-year retirement with a 50/50 stock/bond portfolio.
|
||||
However, it's based on historical U.S. market data and may need
|
||||
adjustment for longer retirements, different asset allocations,
|
||||
or varying market conditions.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Is 4% too conservative or too aggressive?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
It depends on your situation. For early retirees with 40-50+
|
||||
year horizons, 4% might be too aggressive. Some prefer 3-3.5%.
|
||||
Conversely, flexible spenders who can reduce withdrawals in down
|
||||
markets might safely use 4.5-5%. Personal factors like other
|
||||
income sources and spending flexibility matter.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How do I calculate my FIRE number using the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Simply multiply your annual expenses by 25. For example, if you
|
||||
need $40,000 per year, your FIRE number is $1,000,000 ($40,000 ×
|
||||
25). This gives you a portfolio where 4% equals your annual
|
||||
spending needs.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Does the 4% rule account for taxes?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
No, the 4% rule calculates gross withdrawals. You'll need to
|
||||
account for taxes separately based on your account types
|
||||
(traditional vs. Roth IRA, taxable accounts) and tax bracket.
|
||||
Many FIRE planners target a portfolio 25-30% larger than the
|
||||
basic calculation to cover taxes.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-6">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What asset allocation works best with the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
The original Trinity Study found success with portfolios ranging
|
||||
from 50/50 to 75/25 stocks/bonds. Higher stock allocations
|
||||
generally provided better long-term results but with more
|
||||
volatility. Most FIRE practitioners use 60-80% stocks for the
|
||||
growth needed to sustain long retirements.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="bg-foreground/10 mb-12 rounded-lg p-8 text-center">
|
||||
<h2 className="mb-4 text-2xl font-bold">
|
||||
Ready for a More Detailed FIRE Analysis?
|
||||
</h2>
|
||||
<p className="mb-6 text-lg">
|
||||
While the 4% rule provides a great starting point, our comprehensive
|
||||
FIRE calculator offers year-by-year projections, inflation
|
||||
adjustments, and personalized scenarios for your unique situation.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-primary text-primary-foreground inline-block rounded-lg px-6 py-3 font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
Try Our Full FIRE Calculator →
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
345
src/app/calculators/coast-fire/CoastFireCalculator.tsx
Normal file
345
src/app/calculators/coast-fire/CoastFireCalculator.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
||||
export default function CoastFireCalculator() {
|
||||
const [currentAge, setCurrentAge] = useState(30);
|
||||
const [retirementAge, setRetirementAge] = useState(65);
|
||||
const [currentPortfolio, setCurrentPortfolio] = useState(50000);
|
||||
const [annualExpenses, setAnnualExpenses] = useState(50000);
|
||||
const [expectedReturn, setExpectedReturn] = useState(7);
|
||||
|
||||
// Calculate years until retirement
|
||||
const yearsUntilRetirement = retirementAge - currentAge;
|
||||
|
||||
// Calculate target FIRE number (25x annual expenses)
|
||||
const targetFireNumber = annualExpenses * 25;
|
||||
|
||||
// Calculate Coast FIRE number (present value of future FIRE number)
|
||||
const coastFireNumber =
|
||||
yearsUntilRetirement > 0
|
||||
? targetFireNumber /
|
||||
Math.pow(1 + expectedReturn / 100, yearsUntilRetirement)
|
||||
: targetFireNumber;
|
||||
|
||||
// Check if already at Coast FIRE
|
||||
const isCoastFire = currentPortfolio >= coastFireNumber;
|
||||
|
||||
// Calculate what portfolio will grow to by retirement
|
||||
const projectedPortfolioAtRetirement =
|
||||
currentPortfolio * Math.pow(1 + expectedReturn / 100, yearsUntilRetirement);
|
||||
|
||||
// Calculate gap to Coast FIRE
|
||||
const gapToCoastFire = Math.max(coastFireNumber - currentPortfolio, 0);
|
||||
|
||||
// Calculate monthly savings needed to reach Coast FIRE in different timeframes
|
||||
const calculateMonthlySavings = (years: number) => {
|
||||
if (years <= 0 || isCoastFire) return 0;
|
||||
const monthlyReturn = expectedReturn / 100 / 12;
|
||||
const months = years * 12;
|
||||
return (
|
||||
(gapToCoastFire * monthlyReturn) /
|
||||
(Math.pow(1 + monthlyReturn, months) - 1)
|
||||
);
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Input Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Coast FIRE Inputs</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your details to calculate when you can stop saving for
|
||||
retirement
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="current-age">Current Age</Label>
|
||||
<span className="text-sm font-medium">{currentAge}</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="current-age"
|
||||
min={20}
|
||||
max={60}
|
||||
step={1}
|
||||
value={[currentAge]}
|
||||
onValueChange={(value) => setCurrentAge(value[0] ?? 30)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="retirement-age">Target Retirement Age</Label>
|
||||
<span className="text-sm font-medium">{retirementAge}</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="retirement-age"
|
||||
min={40}
|
||||
max={80}
|
||||
step={1}
|
||||
value={[retirementAge]}
|
||||
onValueChange={(value) => setRetirementAge(value[0] ?? 65)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-portfolio">Current Portfolio Value</Label>
|
||||
<Input
|
||||
id="current-portfolio"
|
||||
type="number"
|
||||
value={currentPortfolio}
|
||||
onChange={(e) => setCurrentPortfolio(Number(e.target.value))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your current retirement savings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="annual-expenses">
|
||||
Annual Expenses in Retirement
|
||||
</Label>
|
||||
<Input
|
||||
id="annual-expenses"
|
||||
type="number"
|
||||
value={annualExpenses}
|
||||
onChange={(e) => setAnnualExpenses(Number(e.target.value))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Yearly spending (in today's dollars)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="expected-return">Expected Annual Return</Label>
|
||||
<span className="text-sm font-medium">{expectedReturn}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="expected-return"
|
||||
min={4}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[expectedReturn]}
|
||||
onValueChange={(value) => setExpectedReturn(value[0] ?? 7)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Average annual investment return before inflation
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Your Coast FIRE Number</CardTitle>
|
||||
<CardDescription>
|
||||
Amount needed today to coast to retirement
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-primary text-4xl font-bold">
|
||||
{formatCurrency(coastFireNumber)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Grows to {formatCurrency(targetFireNumber)} in{" "}
|
||||
{yearsUntilRetirement} years
|
||||
</p>
|
||||
{isCoastFire ? (
|
||||
<div className="mt-4 rounded-lg bg-green-100 p-3 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-900 dark:text-green-100">
|
||||
🎉 Congratulations! You've reached Coast FIRE!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-foreground/5 mt-4 rounded-lg p-3">
|
||||
<p className="text-sm font-medium">Progress to Coast FIRE</p>
|
||||
<div className="mt-2 h-2 rounded-full bg-gray-200">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min((currentPortfolio / coastFireNumber) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{Math.round((currentPortfolio / coastFireNumber) * 100)}%
|
||||
complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Portfolio at Retirement</CardTitle>
|
||||
<CardDescription>
|
||||
What your current savings will grow to
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-primary text-4xl font-bold">
|
||||
{formatCurrency(projectedPortfolioAtRetirement)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{projectedPortfolioAtRetirement >= targetFireNumber
|
||||
? `${Math.round((projectedPortfolioAtRetirement / targetFireNumber) * 100)}% of target`
|
||||
: `${formatCurrency(targetFireNumber - projectedPortfolioAtRetirement)} short`}
|
||||
</p>
|
||||
{projectedPortfolioAtRetirement >= targetFireNumber && (
|
||||
<div className="mt-4 rounded-lg bg-blue-100 p-3 dark:bg-blue-900/20">
|
||||
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||
💡 You'll exceed your FIRE target by{" "}
|
||||
{formatCurrency(
|
||||
projectedPortfolioAtRetirement - targetFireNumber,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Savings Scenarios */}
|
||||
{!isCoastFire && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Savings to Reach Coast FIRE</CardTitle>
|
||||
<CardDescription>
|
||||
How much to save monthly to hit Coast FIRE in different timeframes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">In 5 Years</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(calculateMonthlySavings(5))}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">per month</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">In 10 Years</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(calculateMonthlySavings(10))}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">per month</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">In 15 Years</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(calculateMonthlySavings(15))}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">per month</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Key Metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coast FIRE Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Years to Retirement
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{yearsUntilRetirement}</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Target FIRE Number
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(targetFireNumber)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">Gap to Coast FIRE</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(gapToCoastFire)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">Growth Multiple</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{Math.pow(
|
||||
1 + expectedReturn / 100,
|
||||
yearsUntilRetirement,
|
||||
).toFixed(1)}
|
||||
×
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Educational Notes */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/20">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm">
|
||||
<strong>🎯 Coast FIRE Strategy:</strong> Once you hit your Coast
|
||||
FIRE number, you can stop saving for retirement entirely. Work
|
||||
only to cover current expenses while your investments grow to your
|
||||
full FIRE target.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-purple-200 bg-purple-50 dark:border-purple-900 dark:bg-purple-950/20">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm">
|
||||
<strong>⚡ Power of Time:</strong> Starting early is crucial. A
|
||||
25-year-old needs only{" "}
|
||||
{formatCurrency(targetFireNumber / Math.pow(1.07, 40))}
|
||||
to coast to a {formatCurrency(targetFireNumber)} retirement at 65
|
||||
(assuming 7% returns).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
481
src/app/calculators/coast-fire/page.tsx
Normal file
481
src/app/calculators/coast-fire/page.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import BackgroundPattern from "@/app/components/BackgroundPattern";
|
||||
import Footer from "@/app/components/footer";
|
||||
import CoastFireCalculator from "./CoastFireCalculator";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Coast FIRE Calculator - When Can You Stop Saving? | InvestingFIRE",
|
||||
description:
|
||||
"Calculate your Coast FIRE number and find out when you can stop saving for retirement. Free Coast FI calculator shows how compound interest can work for you.",
|
||||
keywords:
|
||||
"coast fire calculator, coast fi calculator, coast fire number, barista fire calculator, coast financial independence, stop saving calculator",
|
||||
openGraph: {
|
||||
title: "Coast FIRE Calculator - Stop Saving & Let Compound Interest Work",
|
||||
description:
|
||||
"Discover when you can stop saving for retirement and coast to financial independence. Free calculator with real-time projections.",
|
||||
type: "website",
|
||||
url: "https://investingfire.com/calculators/coast-fire",
|
||||
},
|
||||
};
|
||||
|
||||
export default function CoastFirePage() {
|
||||
const faqData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "What is Coast FIRE?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Coast FIRE (Financial Independence, Retire Early) is when you've saved enough that you can stop contributing to retirement accounts and still reach your FIRE number by your target retirement age through compound growth alone. You still need to cover current expenses but no longer need to save for retirement.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How is Coast FIRE different from regular FIRE?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Regular FIRE means you have enough saved to retire immediately and live off withdrawals. Coast FIRE means you have enough that will grow to your FIRE number by retirement age without additional contributions. You still work to cover current expenses but can spend everything you earn.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "What's the Coast FIRE formula?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Coast FIRE Number = FIRE Number ÷ (1 + growth rate)^years until retirement. For example, if you need $1 million at 65 and you're 35 with 30 years to go, assuming 7% growth: $1,000,000 ÷ (1.07)^30 = $131,367.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Is Coast FIRE realistic?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Yes, Coast FIRE is very achievable, especially for those who start saving aggressively in their 20s or 30s. The key is front-loading retirement savings early in your career when compound interest has the most time to work. Many achieve Coast FIRE in 10-15 years of focused saving.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: "https://investingfire.com",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Calculators",
|
||||
item: "https://investingfire.com/calculators",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: "Coast FIRE Calculator",
|
||||
item: "https://investingfire.com/calculators/coast-fire",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
|
||||
<BackgroundPattern />
|
||||
|
||||
{/* Header */}
|
||||
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-4 transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Image
|
||||
priority
|
||||
unoptimized
|
||||
src="/investingfire_logo_no-bg.svg"
|
||||
alt="InvestingFIRE Logo"
|
||||
width={60}
|
||||
height={60}
|
||||
/>
|
||||
<span className="text-2xl font-bold">InvestingFIRE</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="from-primary via-primary-foreground to-primary mt-8 bg-gradient-to-r bg-clip-text text-4xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[4rem]">
|
||||
Coast FIRE Calculator
|
||||
</h1>
|
||||
<p className="text-primary-foreground/90 max-w-2xl text-xl font-semibold md:text-2xl">
|
||||
Find Out When You Can Stop Saving and Let Compound Interest Do the
|
||||
Work
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb Schema */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbData) }}
|
||||
/>
|
||||
|
||||
{/* Calculator */}
|
||||
<div className="z-10 mt-8 w-full max-w-4xl">
|
||||
<CoastFireCalculator />
|
||||
</div>
|
||||
|
||||
{/* SEO Content */}
|
||||
<div className="z-10 mx-auto max-w-4xl px-4 py-12 text-left">
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
What Is Coast FIRE? Your Path to Stress-Free Saving
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
<strong>Coast FIRE</strong> represents a powerful milestone in your
|
||||
financial independence journey. It's the point where you've
|
||||
accumulated enough investments that you can completely stop saving
|
||||
for retirement and still reach your FIRE number by your target
|
||||
retirement age through compound growth alone.
|
||||
</p>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Unlike traditional FIRE where you need 25× your annual expenses
|
||||
saved before retiring, Coast FIRE allows you to "coast" to
|
||||
retirement. You still work to cover your current living expenses,
|
||||
but you can spend 100% of your income knowing your future retirement
|
||||
is already secured through the magic of compound interest.
|
||||
</p>
|
||||
|
||||
<div className="bg-foreground/10 my-6 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">Coast FIRE Benefits:</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>Reduced financial stress</strong> - No more pressure to
|
||||
save aggressively
|
||||
</li>
|
||||
<li>
|
||||
<strong>Career flexibility</strong> - Take lower-paying but more
|
||||
fulfilling work
|
||||
</li>
|
||||
<li>
|
||||
<strong>Lifestyle upgrade</strong> - Spend your entire paycheck
|
||||
guilt-free
|
||||
</li>
|
||||
<li>
|
||||
<strong>Early achievement</strong> - Often reachable in your 30s
|
||||
or 40s
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
How the Coast FIRE Calculator Works
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Our Coast FIRE calculator uses the time value of money principle to
|
||||
determine exactly how much you need invested today to reach your
|
||||
retirement goal without any additional contributions. Here's the
|
||||
math behind it:
|
||||
</p>
|
||||
|
||||
<div className="bg-foreground/10 mb-6 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
The Coast FIRE Formula:
|
||||
</h3>
|
||||
<p className="mb-4 font-mono text-lg">
|
||||
Coast FIRE Number = Target FIRE Number ÷ (1 + growth rate)^years
|
||||
</p>
|
||||
<p className="text-lg">Where:</p>
|
||||
<ul className="ml-6 list-disc space-y-1 text-lg">
|
||||
<li>Target FIRE Number = 25× your annual retirement expenses</li>
|
||||
<li>
|
||||
Growth rate = Expected annual investment return (e.g., 7%)
|
||||
</li>
|
||||
<li>Years = Time until your target retirement age</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
For example, if you need $1,000,000 to retire in 30 years and expect
|
||||
7% annual returns, you need just $131,367 invested today to coast to
|
||||
retirement. That's the power of compound interest working over
|
||||
decades!
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Coast FIRE vs Other FIRE Variations
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Understanding how Coast FIRE fits into the broader FIRE movement
|
||||
helps you choose the right strategy for your situation:
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">Coast FIRE</h3>
|
||||
<p className="mb-2">Stop saving, work for expenses only</p>
|
||||
<ul className="ml-4 list-disc space-y-1 text-sm">
|
||||
<li>Achievable in 10-15 years</li>
|
||||
<li>Reduces financial stress immediately</li>
|
||||
<li>Perfect for career pivots</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">Barista FIRE</h3>
|
||||
<p className="mb-2">Similar to Coast but work part-time</p>
|
||||
<ul className="ml-4 list-disc space-y-1 text-sm">
|
||||
<li>Often includes health benefits</li>
|
||||
<li>More lifestyle flexibility</li>
|
||||
<li>Bridge to full retirement</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">Traditional FIRE</h3>
|
||||
<p className="mb-2">Full retirement with 4% rule</p>
|
||||
<ul className="ml-4 list-disc space-y-1 text-sm">
|
||||
<li>Need 25× annual expenses</li>
|
||||
<li>Complete work optional</li>
|
||||
<li>Takes longer to achieve</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">Lean/Fat FIRE</h3>
|
||||
<p className="mb-2">Variations based on spending</p>
|
||||
<ul className="ml-4 list-disc space-y-1 text-sm">
|
||||
<li>Lean: Minimal expenses</li>
|
||||
<li>Fat: Luxury lifestyle</li>
|
||||
<li>Different savings targets</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Strategies to Reach Coast FIRE Faster
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Achieving Coast FIRE is all about front-loading your retirement
|
||||
savings while you're young. Here are proven strategies to accelerate
|
||||
your journey:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
1. Maximize Tax-Advantaged Accounts Early
|
||||
</h3>
|
||||
<p>
|
||||
Prioritize 401(k), IRA, and HSA contributions in your 20s and
|
||||
30s. The tax savings compound alongside your investments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
2. Live on One Income (If Partnered)
|
||||
</h3>
|
||||
<p>
|
||||
Save 100% of one partner's income while living on the other.
|
||||
This can cut your Coast FIRE timeline in half.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
3. House Hack or Geographic Arbitrage
|
||||
</h3>
|
||||
<p>
|
||||
Minimize housing costs through rental income or moving to lower
|
||||
cost areas while maintaining income.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
4. Invest Windfalls Immediately
|
||||
</h3>
|
||||
<p>
|
||||
Bonuses, tax refunds, and gifts go straight to investments.
|
||||
These lump sums have maximum time to compound.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
5. Increase Savings Rate Annually
|
||||
</h3>
|
||||
<p>
|
||||
Bump up your savings by 1-2% each year. You won't feel the pinch
|
||||
but will dramatically accelerate your timeline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="mb-12">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
||||
/>
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Coast FIRE Frequently Asked Questions
|
||||
</h2>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What is Coast FIRE?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Coast FIRE (Financial Independence, Retire Early) is when you've
|
||||
saved enough that you can stop contributing to retirement
|
||||
accounts and still reach your FIRE number by your target
|
||||
retirement age through compound growth alone. You still need to
|
||||
cover current expenses but no longer need to save for
|
||||
retirement.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How is Coast FIRE different from regular FIRE?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Regular FIRE means you have enough saved to retire immediately
|
||||
and live off withdrawals. Coast FIRE means you have enough that
|
||||
will grow to your FIRE number by retirement age without
|
||||
additional contributions. You still work to cover current
|
||||
expenses but can spend everything you earn.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What's the Coast FIRE formula?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Coast FIRE Number = FIRE Number ÷ (1 + growth rate)^years until
|
||||
retirement. For example, if you need $1 million at 65 and you're
|
||||
35 with 30 years to go, assuming 7% growth: $1,000,000 ÷
|
||||
(1.07)^30 = $131,367.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Is Coast FIRE realistic?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Yes, Coast FIRE is very achievable, especially for those who
|
||||
start saving aggressively in their 20s or 30s. The key is
|
||||
front-loading retirement savings early in your career when
|
||||
compound interest has the most time to work. Many achieve Coast
|
||||
FIRE in 10-15 years of focused saving.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What if I already have some retirement savings?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Great! You're already on your way. Enter your current portfolio
|
||||
value in the calculator to see how close you are to Coast FIRE.
|
||||
You might be surprised to find you're closer than you think,
|
||||
especially if you have many years until retirement.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-6">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Should I actually stop saving once I hit Coast FIRE?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
That's a personal choice! Many people continue saving to reach
|
||||
full FIRE faster, build a buffer for market downturns, or
|
||||
upgrade their retirement lifestyle. Others use Coast FIRE as
|
||||
permission to pursue lower-paying passion projects or reduce
|
||||
work hours. The beauty is having options.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
{/* Success Stories */}
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Real Coast FIRE Success Stories
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-2 text-xl font-semibold">The Teacher's Tale</h3>
|
||||
<p className="text-sm">
|
||||
"I hit Coast FIRE at 32 after 10 years of saving 60% as an
|
||||
engineer. Now I teach high school physics—half the pay but 10x
|
||||
the satisfaction. My retirement is secured, so every paycheck
|
||||
goes to enjoying life now."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-2 text-xl font-semibold">
|
||||
The Entrepreneur's Freedom
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
"Reaching Coast FIRE at 38 gave me the courage to start my
|
||||
business. Without needing to save for retirement, I could
|
||||
reinvest everything back into growth. Best decision ever."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="bg-foreground/10 mb-12 rounded-lg p-8 text-center">
|
||||
<h2 className="mb-4 text-2xl font-bold">
|
||||
Want a Complete FIRE Plan?
|
||||
</h2>
|
||||
<p className="mb-6 text-lg">
|
||||
Coast FIRE is just one strategy. Our comprehensive FIRE calculator
|
||||
models your entire journey with multiple scenarios, withdrawal
|
||||
strategies, and detailed projections.
|
||||
</p>
|
||||
<div className="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-primary text-primary-foreground inline-block rounded-lg px-6 py-3 font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
Try Full FIRE Calculator →
|
||||
</Link>
|
||||
<Link
|
||||
href="/calculators/4-percent-rule"
|
||||
className="bg-secondary text-secondary-foreground inline-block rounded-lg px-6 py-3 font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
Explore 4% Rule →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
157
src/app/components/BackgroundPattern.tsx
Normal file
157
src/app/components/BackgroundPattern.tsx
Normal 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>
|
||||
);
|
||||
}
|
741
src/app/components/FireCalculatorForm.tsx
Normal file
741
src/app/components/FireCalculatorForm.tsx
Normal file
@@ -0,0 +1,741 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ReferenceLine,
|
||||
type TooltipProps,
|
||||
} 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
|
||||
const formSchema = z.object({
|
||||
startingCapital: z.coerce.number(),
|
||||
monthlySavings: z.coerce
|
||||
.number()
|
||||
.min(0, "Monthly savings must be a non-negative number"),
|
||||
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"),
|
||||
desiredMonthlyAllowance: z.coerce
|
||||
.number()
|
||||
.min(0, "Monthly allowance must be a non-negative number"),
|
||||
inflationRate: z.coerce
|
||||
.number()
|
||||
.min(0, "Inflation rate must be a non-negative number"),
|
||||
lifeExpectancy: z.coerce
|
||||
.number()
|
||||
.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 FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface YearlyData {
|
||||
age: number;
|
||||
year: number;
|
||||
balance: number;
|
||||
untouchedBalance: number;
|
||||
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() {
|
||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||
const irlYear = new Date().getFullYear();
|
||||
const [showing4percent, setShowing4percent] = useState(false);
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
startingCapital: 50000,
|
||||
monthlySavings: 1500,
|
||||
currentAge: 25,
|
||||
cagr: 7,
|
||||
desiredMonthlyAllowance: 3000,
|
||||
inflationRate: 2.3,
|
||||
lifeExpectancy: 84,
|
||||
retirementAge: 55,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
setResult(null); // Reset previous results
|
||||
|
||||
const startingCapital = values.startingCapital;
|
||||
const monthlySavings = values.monthlySavings;
|
||||
const age = values.currentAge;
|
||||
const annualGrowthRate = 1 + values.cagr / 100;
|
||||
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||
const annualInflation = 1 + values.inflationRate / 100;
|
||||
const ageOfDeath = values.lifeExpectancy;
|
||||
const retirementAge = values.retirementAge;
|
||||
|
||||
// Array to store yearly data for the chart
|
||||
const yearlyData: YearlyData[] = [];
|
||||
|
||||
// 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({
|
||||
age: currentAge,
|
||||
year: year,
|
||||
balance: newBalance,
|
||||
untouchedBalance: untouchedBalance,
|
||||
phase: phase,
|
||||
monthlyAllowance: allowance,
|
||||
untouchedMonthlyAllowance: inflatedAllowance,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate FIRE number at retirement
|
||||
const retirementYear = irlYear + (retirementAge - age);
|
||||
const retirementIndex = yearlyData.findIndex(
|
||||
(data) => data.year === retirementYear,
|
||||
);
|
||||
const retirementData = yearlyData[retirementIndex];
|
||||
|
||||
const [fireNumber4percent, retirementAge4percent] = (() => {
|
||||
for (const yearData of yearlyData) {
|
||||
if (
|
||||
yearData.untouchedBalance >
|
||||
(yearData.untouchedMonthlyAllowance * 12) / 0.04
|
||||
) {
|
||||
return [yearData.untouchedBalance, yearData.age];
|
||||
}
|
||||
}
|
||||
return [0, 0];
|
||||
})();
|
||||
|
||||
if (retirementIndex === -1 || !retirementData) {
|
||||
setResult({
|
||||
fireNumber: null,
|
||||
fireNumber4percent: null,
|
||||
retirementAge4percent: null,
|
||||
error: "Could not calculate retirement data",
|
||||
yearlyData: yearlyData,
|
||||
});
|
||||
} else {
|
||||
// Set the result
|
||||
setResult({
|
||||
fireNumber: retirementData.balance,
|
||||
fireNumber4percent: fireNumber4percent,
|
||||
retirementAge4percent: retirementAge4percent,
|
||||
yearlyData: yearlyData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
|
||||
<CardDescription>
|
||||
Calculate your path to financial independence and retirement
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="startingCapital"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Starting Capital</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 10000"
|
||||
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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="monthlySavings"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Monthly Savings</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 500"
|
||||
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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentAge"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Age</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 30"
|
||||
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>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cagr"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expected Annual Growth Rate (%)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 7"
|
||||
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>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="desiredMonthlyAllowance"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Desired Monthly Allowance (Today's Value)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 2000"
|
||||
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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Retirement Age Slider */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retirementAge"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Retirement Age: {field.value as number}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
name="retirementAge"
|
||||
value={[field.value as number]}
|
||||
min={25}
|
||||
max={75}
|
||||
step={1}
|
||||
onValueChange={(value: number[]) => {
|
||||
field.onChange(value[0]);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
className="py-4"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!result && (
|
||||
<Button type="submit" className="w-full">
|
||||
Calculate
|
||||
</Button>
|
||||
)}
|
||||
{result?.yearlyData && (
|
||||
<Card className="rounded-md shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Financial Projection</CardTitle>
|
||||
<CardDescription>
|
||||
Projected balance growth with your selected retirement age
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2">
|
||||
<ChartContainer
|
||||
className="aspect-auto h-80 w-full"
|
||||
config={{}}
|
||||
>
|
||||
<AreaChart
|
||||
data={result.yearlyData}
|
||||
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="year"
|
||||
label={{
|
||||
value: "Year",
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
}}
|
||||
/>
|
||||
{/* Right Y axis */}
|
||||
<YAxis
|
||||
yAxisId={"right"}
|
||||
orientation="right"
|
||||
tickFormatter={(value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toPrecision(3)}M`;
|
||||
} else if (value >= 1000) {
|
||||
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.toString();
|
||||
}}
|
||||
width={30}
|
||||
stroke="var(--color-orange-500)"
|
||||
tick={{}}
|
||||
/>
|
||||
{/* Left Y axis */}
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
orientation="left"
|
||||
tickFormatter={(value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toPrecision(3)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toPrecision(3)}K`;
|
||||
}
|
||||
return value.toString();
|
||||
}}
|
||||
width={30}
|
||||
stroke="var(--color-red-600)"
|
||||
/>
|
||||
<ChartTooltip content={tooltipRenderer} />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="fillBalance"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-orange-500)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-orange-500)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
name="balance"
|
||||
stroke="var(--color-orange-500)"
|
||||
fill="url(#fillBalance)"
|
||||
fillOpacity={0.9}
|
||||
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 && (
|
||||
<ReferenceLine
|
||||
y={result.fireNumber}
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="2 1"
|
||||
label={{
|
||||
value: "FIRE Number",
|
||||
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"}
|
||||
/>
|
||||
)}
|
||||
<ReferenceLine
|
||||
x={
|
||||
irlYear +
|
||||
(Number(form.getValues("retirementAge")) -
|
||||
Number(form.getValues("currentAge")))
|
||||
}
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
label={{
|
||||
value: "Retirement",
|
||||
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>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
16
src/app/components/footer.tsx
Normal file
16
src/app/components/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
77
src/app/components/web-vitals.tsx
Normal file
77
src/app/components/web-vitals.tsx
Normal 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
BIN
src/app/favicon.ico
(Stored with Git LFS)
Normal file
Binary file not shown.
404
src/app/guides/fire-by-age/FireByAgeCalculator.tsx
Normal file
404
src/app/guides/fire-by-age/FireByAgeCalculator.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export default function FireByAgeCalculator() {
|
||||
const [currentAge, setCurrentAge] = useState(25);
|
||||
const [targetRetirementAge, setTargetRetirementAge] = useState(40);
|
||||
const [currentSavings, setCurrentSavings] = useState(10000);
|
||||
const [annualExpenses, setAnnualExpenses] = useState(50000);
|
||||
const [expectedReturn, setExpectedReturn] = useState(7);
|
||||
const [currentIncome, setCurrentIncome] = useState(75000);
|
||||
|
||||
// Calculate years to retirement
|
||||
const yearsToRetirement = targetRetirementAge - currentAge;
|
||||
|
||||
// Determine withdrawal rate based on retirement age
|
||||
const getWithdrawalRate = (age: number) => {
|
||||
if (age <= 35) return 3;
|
||||
if (age <= 40) return 3.5;
|
||||
if (age <= 45) return 3.75;
|
||||
if (age <= 50) return 4;
|
||||
return 4.25;
|
||||
};
|
||||
|
||||
const withdrawalRate = getWithdrawalRate(targetRetirementAge);
|
||||
const fireMultiplier = 100 / withdrawalRate;
|
||||
|
||||
// Calculate FIRE number
|
||||
const fireNumber = annualExpenses * fireMultiplier;
|
||||
|
||||
// Calculate future value of current savings
|
||||
const futureValueOfCurrentSavings =
|
||||
currentSavings * Math.pow(1 + expectedReturn / 100, yearsToRetirement);
|
||||
|
||||
// Calculate gap
|
||||
const gap = fireNumber - futureValueOfCurrentSavings;
|
||||
|
||||
// Calculate required monthly savings
|
||||
const calculateMonthlySavings = () => {
|
||||
if (yearsToRetirement <= 0 || gap <= 0) return 0;
|
||||
const monthlyReturn = expectedReturn / 100 / 12;
|
||||
const months = yearsToRetirement * 12;
|
||||
return (gap * monthlyReturn) / (Math.pow(1 + monthlyReturn, months) - 1);
|
||||
};
|
||||
|
||||
const requiredMonthlySavings = calculateMonthlySavings();
|
||||
const requiredAnnualSavings = requiredMonthlySavings * 12;
|
||||
const savingsRate = (requiredAnnualSavings / currentIncome) * 100;
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Age-specific recommendations
|
||||
const getRecommendations = () => {
|
||||
if (targetRetirementAge <= 35) {
|
||||
return {
|
||||
difficulty: "Extremely Challenging",
|
||||
color: "text-red-600",
|
||||
tips: [
|
||||
"Requires 60-80% savings rate",
|
||||
"Focus on maximizing income",
|
||||
"Consider geographic arbitrage",
|
||||
"Live extremely frugally",
|
||||
],
|
||||
};
|
||||
} else if (targetRetirementAge <= 40) {
|
||||
return {
|
||||
difficulty: "Very Challenging",
|
||||
color: "text-orange-600",
|
||||
tips: [
|
||||
"Requires 50-60% savings rate",
|
||||
"Maximize career growth",
|
||||
"House hack or minimize housing",
|
||||
"Avoid lifestyle inflation",
|
||||
],
|
||||
};
|
||||
} else if (targetRetirementAge <= 45) {
|
||||
return {
|
||||
difficulty: "Challenging",
|
||||
color: "text-yellow-600",
|
||||
tips: [
|
||||
"Requires 40-50% savings rate",
|
||||
"Build multiple income streams",
|
||||
"Consider Coast FIRE strategy",
|
||||
"Plan for healthcare costs",
|
||||
],
|
||||
};
|
||||
} else if (targetRetirementAge <= 50) {
|
||||
return {
|
||||
difficulty: "Moderate",
|
||||
color: "text-blue-600",
|
||||
tips: [
|
||||
"Requires 30-40% savings rate",
|
||||
"Use tax-advantaged accounts",
|
||||
"Consider part-time work",
|
||||
"Plan Roth conversions",
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
difficulty: "Achievable",
|
||||
color: "text-green-600",
|
||||
tips: [
|
||||
"Requires 25-35% savings rate",
|
||||
"Maximize 401(k) contributions",
|
||||
"Use Rule of 55 if applicable",
|
||||
"Bridge to Social Security",
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const recommendations = getRecommendations();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Input Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your FIRE by Age Inputs</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your details to see what it takes to retire at your target age
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="current-age">Current Age</Label>
|
||||
<span className="text-sm font-medium">{currentAge}</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="current-age"
|
||||
min={20}
|
||||
max={55}
|
||||
step={1}
|
||||
value={[currentAge]}
|
||||
onValueChange={(value) => setCurrentAge(value[0] ?? 25)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target-age">Target Retirement Age</Label>
|
||||
<Select
|
||||
value={targetRetirementAge.toString()}
|
||||
onValueChange={(value) => setTargetRetirementAge(Number(value))}
|
||||
>
|
||||
<SelectTrigger id="target-age">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30">Retire at 30</SelectItem>
|
||||
<SelectItem value="35">Retire at 35</SelectItem>
|
||||
<SelectItem value="40">Retire at 40</SelectItem>
|
||||
<SelectItem value="45">Retire at 45</SelectItem>
|
||||
<SelectItem value="50">Retire at 50</SelectItem>
|
||||
<SelectItem value="55">Retire at 55</SelectItem>
|
||||
<SelectItem value="60">Retire at 60</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-savings">
|
||||
Current Retirement Savings
|
||||
</Label>
|
||||
<Input
|
||||
id="current-savings"
|
||||
type="number"
|
||||
value={currentSavings}
|
||||
onChange={(e) => setCurrentSavings(Number(e.target.value))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Total invested for retirement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-income">Current Annual Income</Label>
|
||||
<Input
|
||||
id="current-income"
|
||||
type="number"
|
||||
value={currentIncome}
|
||||
onChange={(e) => setCurrentIncome(Number(e.target.value))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pre-tax annual income
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="annual-expenses">
|
||||
Annual Expenses in Retirement
|
||||
</Label>
|
||||
<Input
|
||||
id="annual-expenses"
|
||||
type="number"
|
||||
value={annualExpenses}
|
||||
onChange={(e) => setAnnualExpenses(Number(e.target.value))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Yearly spending needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="expected-return">Expected Annual Return</Label>
|
||||
<span className="text-sm font-medium">{expectedReturn}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="expected-return"
|
||||
min={4}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[expectedReturn]}
|
||||
onValueChange={(value) => setExpectedReturn(value[0] ?? 7)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Your FIRE Number</CardTitle>
|
||||
<CardDescription>
|
||||
Target for retiring at {targetRetirementAge}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-primary text-4xl font-bold">
|
||||
{formatCurrency(fireNumber)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{fireMultiplier.toFixed(1)}× annual expenses ({withdrawalRate}%
|
||||
withdrawal rate)
|
||||
</p>
|
||||
<div className="bg-foreground/5 mt-4 rounded-lg p-3">
|
||||
<p className="text-sm font-medium">Years to Retirement</p>
|
||||
<p className="text-2xl font-bold">{yearsToRetirement}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Required Monthly Savings</CardTitle>
|
||||
<CardDescription>To reach your FIRE goal</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-primary text-4xl font-bold">
|
||||
{formatCurrency(requiredMonthlySavings)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{formatCurrency(requiredAnnualSavings)}/year
|
||||
</p>
|
||||
<div className="bg-foreground/5 mt-4 rounded-lg p-3">
|
||||
<p className="text-sm font-medium">Savings Rate Required</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{Math.min(savingsRate, 100).toFixed(0)}%
|
||||
</p>
|
||||
{savingsRate > 100 && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
⚠️ Exceeds current income
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Assessment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Difficulty Assessment: Retiring at {targetRetirementAge}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<p className="text-lg font-medium">Difficulty Level:</p>
|
||||
<p className={`text-3xl font-bold ${recommendations.color}`}>
|
||||
{recommendations.difficulty}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-3 text-lg font-medium">Key Success Factors:</p>
|
||||
<ul className="ml-6 list-disc space-y-2">
|
||||
{recommendations.tips.map((tip, idx) => (
|
||||
<li key={idx} className="text-lg">
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Path to FIRE at {targetRetirementAge}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Current Savings Growth
|
||||
</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(futureValueOfCurrentSavings)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">Additional Needed</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(Math.max(gap, 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Total Contributions
|
||||
</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(requiredAnnualSavings * yearsToRetirement)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-foreground/5 rounded-lg p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">Investment Growth</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(
|
||||
fireNumber -
|
||||
currentSavings -
|
||||
requiredAnnualSavings * yearsToRetirement,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips Card */}
|
||||
{savingsRate > 50 && (
|
||||
<Card className="border-orange-200 bg-orange-50 dark:border-orange-900 dark:bg-orange-950/20">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm">
|
||||
<strong>⚡ High Savings Rate Required:</strong> Achieving a{" "}
|
||||
{savingsRate.toFixed(0)}% savings rate is challenging. Consider
|
||||
increasing income through side hustles, reducing major expenses
|
||||
like housing/transportation, or adjusting your target retirement
|
||||
age to {targetRetirementAge + 5} for a more manageable{" "}
|
||||
{(
|
||||
((requiredAnnualSavings * yearsToRetirement) /
|
||||
((targetRetirementAge + 5 - currentAge) * currentIncome)) *
|
||||
100
|
||||
).toFixed(0)}
|
||||
% savings rate.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
625
src/app/guides/fire-by-age/page.tsx
Normal file
625
src/app/guides/fire-by-age/page.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import BackgroundPattern from "@/app/components/BackgroundPattern";
|
||||
import Footer from "@/app/components/footer";
|
||||
import FireByAgeCalculator from "./FireByAgeCalculator";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "FIRE by Age Guide - Retire at 30, 35, 40, 45, 50, 55 | InvestingFIRE",
|
||||
description:
|
||||
"Complete guide to achieving FIRE at any age. Learn how much you need to retire at 30, 35, 40, 45, 50, or 55. Free calculator with age-specific strategies and savings targets.",
|
||||
keywords:
|
||||
"retire at 40, retire at 45, retire at 50, retire at 35, retire at 30, early retirement by age, FIRE age calculator, how much to retire at 40",
|
||||
openGraph: {
|
||||
title: "FIRE by Age Guide - When Can You Retire?",
|
||||
description:
|
||||
"Discover exactly how much you need to retire at 30, 35, 40, 45, 50, or 55. Complete guide with calculator and age-specific strategies.",
|
||||
type: "website",
|
||||
url: "https://investingfire.com/guides/fire-by-age",
|
||||
},
|
||||
};
|
||||
|
||||
export default function FireByAgePage() {
|
||||
const faqData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How much do I need to retire at 40?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "To retire at 40, you typically need 25-30x your annual expenses saved. For $50,000/year in expenses, that's $1.25-1.5 million. The higher multiplier accounts for a longer retirement period. Starting at 25, you'd need to save about $3,000-4,000/month assuming 7% returns.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Can I retire at 50 with $1 million?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Yes, you can retire at 50 with $1 million if your annual expenses are $40,000 or less (using the 4% rule). For a more conservative 3.5% withdrawal rate, you'd need expenses under $35,000/year. Consider that you'll have 15 years before Medicare eligibility, so factor in health insurance costs.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "What's the best age to retire early?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "The 'best' age depends on your personal circumstances, but many FIRE achievers target 40-50. This balances having enough working years to accumulate wealth with plenty of healthy retirement years. Earlier retirement requires more aggressive saving and potentially lower withdrawal rates.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How does retirement age affect withdrawal rates?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Younger retirees should use lower withdrawal rates. While the 4% rule works for 30-year retirements, consider: Age 30-35: 3-3.25% withdrawal rate. Age 40-45: 3.5% withdrawal rate. Age 50-55: 3.75-4% withdrawal rate. Age 60+: 4-4.5% withdrawal rate.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: "https://investingfire.com",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Guides",
|
||||
item: "https://investingfire.com/guides",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: "FIRE by Age",
|
||||
item: "https://investingfire.com/guides/fire-by-age",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ageTargets = [
|
||||
{
|
||||
age: 30,
|
||||
multiplier: 33,
|
||||
withdrawalRate: 3,
|
||||
savingsYears: "5-10",
|
||||
challenges: [
|
||||
"Extremely aggressive saving required",
|
||||
"Limited career earnings time",
|
||||
"60+ year retirement horizon",
|
||||
],
|
||||
strategies: [
|
||||
"Save 70-80% of income",
|
||||
"High-income career essential",
|
||||
"Consider geographic arbitrage",
|
||||
],
|
||||
},
|
||||
{
|
||||
age: 35,
|
||||
multiplier: 30,
|
||||
withdrawalRate: 3.25,
|
||||
savingsYears: "10-15",
|
||||
challenges: [
|
||||
"Very high savings rate needed",
|
||||
"Family formation years",
|
||||
"55+ year retirement",
|
||||
],
|
||||
strategies: [
|
||||
"Save 60-70% of income",
|
||||
"Maximize career growth",
|
||||
"House hack or minimize housing",
|
||||
],
|
||||
},
|
||||
{
|
||||
age: 40,
|
||||
multiplier: 28,
|
||||
withdrawalRate: 3.5,
|
||||
savingsYears: "15-20",
|
||||
challenges: [
|
||||
"Peak earning years cut short",
|
||||
"Children's education costs",
|
||||
"Healthcare before Medicare",
|
||||
],
|
||||
strategies: [
|
||||
"Save 50-60% of income",
|
||||
"Build multiple income streams",
|
||||
"Plan for health insurance",
|
||||
],
|
||||
},
|
||||
{
|
||||
age: 45,
|
||||
multiplier: 27,
|
||||
withdrawalRate: 3.75,
|
||||
savingsYears: "20-25",
|
||||
challenges: [
|
||||
"Mid-career transition",
|
||||
"Aging parents care",
|
||||
"20 years to Medicare",
|
||||
],
|
||||
strategies: [
|
||||
"Save 40-50% of income",
|
||||
"Consider Coast FIRE first",
|
||||
"Build health insurance fund",
|
||||
],
|
||||
},
|
||||
{
|
||||
age: 50,
|
||||
multiplier: 25,
|
||||
withdrawalRate: 4,
|
||||
savingsYears: "25-30",
|
||||
challenges: [
|
||||
"Early retirement penalties",
|
||||
"15 years to Medicare",
|
||||
"Sequence of returns risk",
|
||||
],
|
||||
strategies: [
|
||||
"Save 30-40% of income",
|
||||
"Ladder conversions",
|
||||
"Part-time work option",
|
||||
],
|
||||
},
|
||||
{
|
||||
age: 55,
|
||||
multiplier: 25,
|
||||
withdrawalRate: 4.25,
|
||||
savingsYears: "30-35",
|
||||
challenges: [
|
||||
"10 years to Medicare",
|
||||
"Social Security timing",
|
||||
"Market volatility impact",
|
||||
],
|
||||
strategies: [
|
||||
"Save 25-35% of income",
|
||||
"Rule of 55 withdrawals",
|
||||
"Bridge account planning",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
|
||||
<BackgroundPattern />
|
||||
|
||||
{/* Header */}
|
||||
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-4 transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Image
|
||||
priority
|
||||
unoptimized
|
||||
src="/investingfire_logo_no-bg.svg"
|
||||
alt="InvestingFIRE Logo"
|
||||
width={60}
|
||||
height={60}
|
||||
/>
|
||||
<span className="text-2xl font-bold">InvestingFIRE</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="from-primary via-primary-foreground to-primary mt-8 bg-gradient-to-r bg-clip-text text-4xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[4rem]">
|
||||
FIRE by Age Guide
|
||||
</h1>
|
||||
<p className="text-primary-foreground/90 max-w-2xl text-xl font-semibold md:text-2xl">
|
||||
Complete Guide to Retiring at 30, 35, 40, 45, 50, or 55
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb Schema */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbData) }}
|
||||
/>
|
||||
|
||||
{/* Calculator */}
|
||||
<div className="z-10 mt-8 w-full max-w-4xl">
|
||||
<FireByAgeCalculator />
|
||||
</div>
|
||||
|
||||
{/* SEO Content */}
|
||||
<div className="z-10 mx-auto max-w-4xl px-4 py-12 text-left">
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Your Complete Guide to FIRE at Any Age
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Achieving{" "}
|
||||
<strong>Financial Independence, Retire Early (FIRE)</strong> is
|
||||
possible at virtually any age, but the strategies, savings rates,
|
||||
and challenges vary dramatically depending on when you want to
|
||||
retire. This comprehensive guide breaks down exactly what it takes
|
||||
to retire at 30, 35, 40, 45, 50, or 55.
|
||||
</p>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
The younger your target retirement age, the more aggressive your
|
||||
approach needs to be. While retiring at 55 might require saving
|
||||
25-35% of your income, retiring at 35 could demand 60-70% savings
|
||||
rates and significant lifestyle adjustments. Let's explore what's
|
||||
realistic for each age milestone.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Age-Specific Sections */}
|
||||
{ageTargets.map((target) => (
|
||||
<section
|
||||
key={target.age}
|
||||
className="mb-12 scroll-mt-20"
|
||||
id={`retire-at-${target.age}`}
|
||||
>
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
How to Retire at {target.age}: Complete Strategy
|
||||
</h2>
|
||||
|
||||
<div className="bg-foreground/10 mb-6 rounded-lg p-6">
|
||||
<h3 className="mb-4 text-xl font-semibold">
|
||||
Quick Facts: Retiring at {target.age}
|
||||
</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="font-medium">Target Multiple:</p>
|
||||
<p className="text-primary text-2xl font-bold">
|
||||
{target.multiplier}× annual expenses
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Safe Withdrawal Rate:</p>
|
||||
<p className="text-primary text-2xl font-bold">
|
||||
{target.withdrawalRate}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Typical Saving Period:</p>
|
||||
<p className="text-primary text-2xl font-bold">
|
||||
{target.savingsYears} years
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">For $50k/year expenses:</p>
|
||||
<p className="text-primary text-2xl font-bold">
|
||||
${(target.multiplier * 50).toLocaleString()}k needed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">Key Challenges</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
{target.challenges.map((challenge, idx) => (
|
||||
<li key={idx}>{challenge}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Winning Strategies
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
{target.strategies.map((strategy, idx) => (
|
||||
<li key={idx}>{strategy}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-relaxed">
|
||||
{target.age <= 35 &&
|
||||
"This ultra-early retirement requires exceptional discipline and often a very high income. Most successful retirees at this age work in tech, finance, or have entrepreneurial success. Geographic arbitrage is almost essential."}
|
||||
{target.age > 35 &&
|
||||
target.age <= 45 &&
|
||||
"This is the sweet spot for many FIRE achievers - enough time to build wealth while still having decades of healthy retirement. Focus on maximizing your peak earning years and maintaining a high savings rate."}
|
||||
{target.age > 45 &&
|
||||
"This more traditional early retirement timeline allows for a balanced approach. You'll have more time to benefit from compound growth and can use strategies like the Rule of 55 for penalty-free 401(k) access."}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Age-Specific FIRE Strategies Comparison
|
||||
</h2>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="bg-foreground/5 w-full border-collapse rounded-lg">
|
||||
<thead>
|
||||
<tr className="border-foreground/20 border-b">
|
||||
<th className="p-4 text-left">Retirement Age</th>
|
||||
<th className="p-4 text-center">Savings Rate</th>
|
||||
<th className="p-4 text-center">Years to Save</th>
|
||||
<th className="p-4 text-center">Withdrawal Rate</th>
|
||||
<th className="p-4 text-center">Risk Level</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-foreground/10 border-b">
|
||||
<td className="p-4 font-medium">Retire at 30</td>
|
||||
<td className="p-4 text-center">70-80%</td>
|
||||
<td className="p-4 text-center">5-10</td>
|
||||
<td className="p-4 text-center">3%</td>
|
||||
<td className="p-4 text-center text-red-600">Very High</td>
|
||||
</tr>
|
||||
<tr className="border-foreground/10 border-b">
|
||||
<td className="p-4 font-medium">Retire at 35</td>
|
||||
<td className="p-4 text-center">60-70%</td>
|
||||
<td className="p-4 text-center">10-15</td>
|
||||
<td className="p-4 text-center">3.25%</td>
|
||||
<td className="p-4 text-center text-orange-600">High</td>
|
||||
</tr>
|
||||
<tr className="border-foreground/10 border-b">
|
||||
<td className="p-4 font-medium">Retire at 40</td>
|
||||
<td className="p-4 text-center">50-60%</td>
|
||||
<td className="p-4 text-center">15-20</td>
|
||||
<td className="p-4 text-center">3.5%</td>
|
||||
<td className="p-4 text-center text-yellow-600">
|
||||
Moderate-High
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-foreground/10 border-b">
|
||||
<td className="p-4 font-medium">Retire at 45</td>
|
||||
<td className="p-4 text-center">40-50%</td>
|
||||
<td className="p-4 text-center">20-25</td>
|
||||
<td className="p-4 text-center">3.75%</td>
|
||||
<td className="p-4 text-center text-blue-600">Moderate</td>
|
||||
</tr>
|
||||
<tr className="border-foreground/10 border-b">
|
||||
<td className="p-4 font-medium">Retire at 50</td>
|
||||
<td className="p-4 text-center">30-40%</td>
|
||||
<td className="p-4 text-center">25-30</td>
|
||||
<td className="p-4 text-center">4%</td>
|
||||
<td className="p-4 text-center text-green-600">
|
||||
Low-Moderate
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-4 font-medium">Retire at 55</td>
|
||||
<td className="p-4 text-center">25-35%</td>
|
||||
<td className="p-4 text-center">30-35</td>
|
||||
<td className="p-4 text-center">4.25%</td>
|
||||
<td className="p-4 text-center text-green-600">Low</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Critical Considerations by Retirement Age
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Healthcare Coverage Gap
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>Retire at 30-40:</strong> 25-35 years until Medicare -
|
||||
Budget $15-25k/year for health insurance
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retire at 45-50:</strong> 15-20 years gap - Consider
|
||||
ACA subsidies and HSA maximization
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retire at 55:</strong> 10-year gap - Explore COBRA,
|
||||
spouse's plan, or private insurance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Social Security Strategy
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>Retire before 35:</strong> Minimal SS benefits - Plan
|
||||
without relying on it
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retire at 40-45:</strong> Reduced benefits - Factor in
|
||||
25-50% of normal benefit
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retire at 50-55:</strong> Near-full benefits - Can be
|
||||
significant income supplement
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Investment Allocation
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>60+ year retirement:</strong> 80-90% stocks for
|
||||
growth, rebalance gradually
|
||||
</li>
|
||||
<li>
|
||||
<strong>40-50 year retirement:</strong> 70-80% stocks,
|
||||
consider bond ladder for first decade
|
||||
</li>
|
||||
<li>
|
||||
<strong>30-40 year retirement:</strong> 60-70% stocks,
|
||||
traditional balanced approach
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="mb-12">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
||||
/>
|
||||
<h2 className="mb-4 text-3xl font-bold">FIRE by Age FAQ</h2>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How much do I need to retire at 40?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
To retire at 40, you typically need 25-30x your annual expenses
|
||||
saved. For $50,000/year in expenses, that's $1.25-1.5 million.
|
||||
The higher multiplier accounts for a longer retirement period.
|
||||
Starting at 25, you'd need to save about $3,000-4,000/month
|
||||
assuming 7% returns.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Can I retire at 50 with $1 million?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Yes, you can retire at 50 with $1 million if your annual
|
||||
expenses are $40,000 or less (using the 4% rule). For a more
|
||||
conservative 3.5% withdrawal rate, you'd need expenses under
|
||||
$35,000/year. Consider that you'll have 15 years before Medicare
|
||||
eligibility, so factor in health insurance costs.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What's the best age to retire early?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
The "best" age depends on your personal circumstances, but many
|
||||
FIRE achievers target 40-50. This balances having enough working
|
||||
years to accumulate wealth with plenty of healthy retirement
|
||||
years. Earlier retirement requires more aggressive saving and
|
||||
potentially lower withdrawal rates.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How does retirement age affect withdrawal rates?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Younger retirees should use lower withdrawal rates. While the 4%
|
||||
rule works for 30-year retirements, consider:
|
||||
<ul className="mt-2 ml-6 list-disc">
|
||||
<li>Age 30-35: 3-3.25% withdrawal rate</li>
|
||||
<li>Age 40-45: 3.5% withdrawal rate</li>
|
||||
<li>Age 50-55: 3.75-4% withdrawal rate</li>
|
||||
<li>Age 60+: 4-4.5% withdrawal rate</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Is retiring at 35 realistic?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Retiring at 35 is challenging but achievable with the right
|
||||
circumstances: high income ($100k+), low expenses, 60-70%
|
||||
savings rate, and disciplined investing. Most who achieve this
|
||||
work in high-paying fields, live frugally, and often have no
|
||||
children or delay having them. Geographic arbitrage to low-cost
|
||||
areas helps significantly.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-6">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What about retiring with kids?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Retiring early with children adds complexity but is doable. Key
|
||||
considerations: Budget $10-15k per child annually, plan for
|
||||
college (529 plans), factor in larger housing needs, and
|
||||
consider part-time work for stability. Many FIRE families find
|
||||
that having more time with kids is worth the extra financial
|
||||
planning required.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
{/* Quick Reference */}
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
Quick Reference: FIRE Numbers by Age
|
||||
</h2>
|
||||
<div className="bg-foreground/10 rounded-lg p-6">
|
||||
<p className="mb-4 text-lg font-medium">
|
||||
Based on $50,000 annual expenses:
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{ageTargets.map((target) => (
|
||||
<div
|
||||
key={target.age}
|
||||
className="bg-background/50 flex justify-between rounded p-3"
|
||||
>
|
||||
<span className="font-medium">Retire at {target.age}:</span>
|
||||
<span className="text-primary font-bold">
|
||||
${(target.multiplier * 50).toLocaleString()},000
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="bg-foreground/10 mb-12 rounded-lg p-8 text-center">
|
||||
<h2 className="mb-4 text-2xl font-bold">
|
||||
Ready to Plan Your Early Retirement?
|
||||
</h2>
|
||||
<p className="mb-6 text-lg">
|
||||
Use our comprehensive calculators to create your personalized FIRE
|
||||
plan, whether you're targeting retirement at 35 or 55.
|
||||
</p>
|
||||
<div className="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-primary text-primary-foreground inline-block rounded-lg px-6 py-3 font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
FIRE Calculator →
|
||||
</Link>
|
||||
<Link
|
||||
href="/calculators/coast-fire"
|
||||
className="bg-secondary text-secondary-foreground inline-block rounded-lg px-6 py-3 font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
Coast FIRE Calculator →
|
||||
</Link>
|
||||
<Link
|
||||
href="/calculators/4-percent-rule"
|
||||
className="bg-secondary text-secondary-foreground inline-block rounded-lg px-6 py-3 font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
4% Rule Calculator →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
1
src/app/icon0.svg
Normal file
1
src/app/icon0.svg
Normal 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
BIN
src/app/icon1.png
(Stored with Git LFS)
Normal file
Binary file not shown.
41
src/app/layout.tsx
Normal file
41
src/app/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import "@/styles/globals.css";
|
||||
import PlausibleProvider from "next-plausible";
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
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 = {
|
||||
title: "InvestingFIRE | Finance and Retirement Calculator",
|
||||
description:
|
||||
"Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personalized projections in buttersmooth graphs.",
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<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>
|
||||
</html>
|
||||
);
|
||||
}
|
21
src/app/manifest.json
Normal file
21
src/app/manifest.json
Normal 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"
|
||||
}
|
456
src/app/page.tsx
Normal file
456
src/app/page.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import Image from "next/image";
|
||||
import FireCalculatorForm from "./components/FireCalculatorForm";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import Footer from "./components/footer";
|
||||
import BackgroundPattern from "./components/BackgroundPattern";
|
||||
|
||||
export default function HomePage() {
|
||||
const faqData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "What methodology does this calculator use?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you'll have at retirement (your “FIRE Number”) and how long it will last until your chosen life expectancy.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Why isn't this just the 4% rule?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How do I choose a realistic growth rate?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running “what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How does inflation factor into my FIRE Number?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Can I really retire early with FIRE?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How should I use this calculator effectively?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore “early” vs. “traditional” scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
|
||||
<BackgroundPattern />
|
||||
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<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>
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Added SEO Content Sections */}
|
||||
<div className="z-10 mx-auto max-w-2xl py-12 text-left">
|
||||
<section className="mb-12">
|
||||
<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">
|
||||
FIRE stands for{" "}
|
||||
<strong>Financial Independence, Retire Early</strong>. It's a
|
||||
lifestyle movement built around two core ideas:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>Aggressive saving & investing</strong>—often 50%+ of
|
||||
income—so your capital grows rapidly.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Passive-income coverage</strong>—when your investment
|
||||
returns exceed your living expenses, you gain freedom from a
|
||||
traditional 9-5.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-lg leading-relaxed">
|
||||
By reaching your personal <em>FIRE Number</em>—the nest egg needed
|
||||
to cover your inflation-adjusted spending—you unlock the option to
|
||||
step away from a daily paycheck and pursue passion projects, travel,
|
||||
family, or anything else. This calculator helps you simulate your
|
||||
journey, estimate how much you need, and visualize both your
|
||||
accumulation phase and your retirement withdrawals over time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
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'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">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
||||
/>
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
FIRE & Investing Frequently Asked Questions (FAQ)
|
||||
</h2>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What methodology does this calculator use?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
We run a multi-year projection in two phases:
|
||||
<ol className="ml-6 list-decimal space-y-1">
|
||||
<li>
|
||||
<strong>Accumulation:</strong> Your balance grows by CAGR
|
||||
and you add monthly savings.
|
||||
</li>
|
||||
<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'll have
|
||||
at retirement (your “FIRE Number”) and how long it will last
|
||||
until your chosen life expectancy.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Why isn't this just the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
The 4% rule is a useful starting point (25× annual spending),
|
||||
but it assumes a fixed withdrawal rate with inflation
|
||||
adjustments and doesn't model ongoing savings or dynamic
|
||||
market returns. Our calculator simulates each year's
|
||||
growth, contributions, and inflation-indexed withdrawals to give
|
||||
you a tailored picture.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How do I choose a realistic growth rate?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Historically, a diversified portfolio of equities and bonds has
|
||||
returned around 7-10% per year before inflation. We recommend
|
||||
starting around 6-8% (net of fees), then running “what-if”
|
||||
scenarios—5% on the conservative side, 10% on the aggressive
|
||||
side—to see how they affect your timeline.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How does inflation factor into my FIRE Number?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Cost of living rises. To maintain today's lifestyle, your
|
||||
monthly allowance must grow each year by your inflation rate.
|
||||
This calculator automatically inflates your desired monthly
|
||||
spending and subtracts it from your portfolio during retirement,
|
||||
ensuring your FIRE Number keeps pace with rising expenses.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Can I really retire early with FIRE?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Early retirement is achievable with disciplined saving, smart
|
||||
investing, and realistic assumptions. This tool helps you set
|
||||
targets, visualize outcomes, and adjust inputs—so you can build
|
||||
confidence in your plan and make informed trade-offs between
|
||||
lifestyle, risk, and timeline.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-6">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How should I use this calculator effectively?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
<ul className="ml-6 list-disc space-y-1">
|
||||
<li>
|
||||
Start with your actual numbers (capital, savings, age).
|
||||
</li>
|
||||
<li>
|
||||
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 chart—especially the reference lines—to 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>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
{/* Optional: Add a section for relevant resources/links here */}
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
FIRE Journey & Investing Resources
|
||||
</h2>
|
||||
<p className="mb-6 text-lg leading-relaxed">
|
||||
Ready to deepen your knowledge and build a bullet-proof plan? Below
|
||||
are some of our favorite blogs, books, tools, and communities for
|
||||
financial independence and smart investing.
|
||||
</p>
|
||||
|
||||
<div className="bg-foreground my-8 rounded-md p-4 text-lg">
|
||||
<p className="font-semibold">Getting Started with FIRE:</p>
|
||||
<ol className="ml-6 list-decimal space-y-1">
|
||||
<li>
|
||||
Run your first projection above to find your target FIRE Number.
|
||||
</li>
|
||||
<li>Identify areas to boost savings or reduce expenses.</li>
|
||||
<li>
|
||||
Study index-fund strategies and low-cost investing advice.
|
||||
</li>
|
||||
<li>
|
||||
Join{" "}
|
||||
<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>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">Blogs & Websites</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.mrmoneymustache.com/"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Mr. Money Mustache
|
||||
</a>{" "}
|
||||
- Hardcore frugality & early retirement success stories.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.playingwithfire.co/"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Playing With FIRE
|
||||
</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>
|
||||
<a
|
||||
href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
InvestingFIRE Calculator Demo
|
||||
</a>{" "}
|
||||
- Deep dive on how interactive projections can guide your
|
||||
plan.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Additional Calculators & Tools
|
||||
</h3>
|
||||
<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>
|
||||
<a
|
||||
href="https://walletburst.com/tools/coast-fire-calculator/"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Coast FIRE Calculator
|
||||
</a>{" "}
|
||||
- When you “max out” early contributions but let compounding
|
||||
do the rest.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Compound Interest Calculator
|
||||
</a>{" "}
|
||||
- Explore the power of growth rates in isolation.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
11
src/app/robots.ts
Normal file
11
src/app/robots.ts
Normal 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",
|
||||
};
|
||||
}
|
31
src/app/sitemap.ts
Normal file
31
src/app/sitemap.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/calculators/4-percent-rule`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/calculators/coast-fire`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/guides/fire-by-age`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
];
|
||||
}
|
69
src/components/ui/accordion.tsx
Normal file
69
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn(
|
||||
"border-primary-foreground/20 border-b last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-primary-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
357
src/components/ui/chart.tsx
Normal file
357
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>;
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor: string | undefined =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
color ?? item.payload.fill ?? item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
164
src/components/ui/form.tsx
Normal file
164
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal 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,
|
||||
};
|
63
src/components/ui/slider.tsx
Normal file
63
src/components/ui/slider.tsx
Normal 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
40
src/env.js
Normal 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
1
src/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const BASE_URL = "https://investingfire.com/";
|
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
132
src/styles/globals.css
Normal file
132
src/styles/globals.css
Normal file
@@ -0,0 +1,132 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||
--card: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--card-foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||
--popover: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--popover-foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||
--primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--secondary: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--muted: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
|
||||
--muted-foreground: oklch(
|
||||
0.39 0.0215 96.47 / 80%
|
||||
); /* black olive with opacity */
|
||||
--accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||
--border: oklch(0.67 0.0763 198.81 / 30%); /* verdigris with opacity */
|
||||
--input: oklch(0.67 0.0763 198.81 / 30%); /* verdigris with opacity */
|
||||
--ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--chart-1: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--chart-2: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--chart-3: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||
--chart-4: oklch(0.39 0.0215 96.47); /* black olive */
|
||||
--chart-5: oklch(0.67 0.0763 198.81 / 70%); /* verdigris with opacity */
|
||||
--sidebar: oklch(0.49 0.1326 259.29 / 10%); /* denim with opacity */
|
||||
--sidebar-foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||
--sidebar-primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-border: oklch(
|
||||
0.67 0.0763 198.81 / 20%
|
||||
); /* verdigris with opacity */
|
||||
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.39 0.0215 96.47); /* black olive */
|
||||
--foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--card: oklch(0.39 0.0215 96.47 / 80%); /* black olive with opacity */
|
||||
--card-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--popover: oklch(0.39 0.0215 96.47); /* black olive */
|
||||
--popover-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--secondary: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--muted: oklch(0.39 0.0215 96.47 / 70%); /* black olive with opacity */
|
||||
--muted-foreground: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||
--border: oklch(0.97 0.0228 95.96 / 20%); /* cosmic latte with opacity */
|
||||
--input: oklch(0.97 0.0228 95.96 / 20%); /* cosmic latte with opacity */
|
||||
--ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--chart-1: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--chart-2: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--chart-3: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||
--chart-4: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--chart-5: oklch(0.67 0.0763 198.81 / 70%); /* verdigris with opacity */
|
||||
--sidebar: oklch(0.39 0.0215 96.47 / 90%); /* black olive with opacity */
|
||||
--sidebar-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-border: oklch(
|
||||
0.97 0.0228 95.96 / 10%
|
||||
); /* cosmic latte with opacity */
|
||||
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
42
tsconfig.json
Normal file
42
tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Base Options: */
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.cjs",
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Reference in New Issue
Block a user