├── .eslintrc ├── .gitignore ├── .node-version ├── README.md ├── app ├── components │ ├── About.tsx │ ├── Chart.tsx │ ├── CompForm.tsx │ ├── CompModal.tsx │ ├── CompTable.tsx │ ├── FormField.tsx │ ├── Logo.tsx │ ├── Modal.tsx │ └── Navigation.tsx ├── entry.client.tsx ├── entry.server.tsx ├── global.css ├── lib │ ├── comp.ts │ └── formProps.ts ├── root.tsx └── routes │ ├── api │ ├── search.tsx │ └── stock.tsx │ └── index.tsx ├── package-lock.json ├── package.json ├── public ├── _headers └── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── remix.config.js ├── remix.env.d.ts ├── scripts └── dev-wrangler.mjs ├── server.js ├── tailwind.config.js └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /functions/\[\[path\]\].js 5 | /functions/\[\[path\]\].js.map 6 | /public/build 7 | .env 8 | 9 | /app/tailwind.css 10 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16.17.0 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Total Compensation Calculator 2 | 3 | [🚀 Live Site](https://tc.kyh.io) 4 | 5 | > Understand your total compensation under current market conditions. 6 | 7 | ![alt text](https://cdn.dribbble.com/users/237579/screenshots/18254177/media/05fc481a5b735fadc969d6d905a25357.png?resize=800x600) 8 | 9 | The term Total Compensation captures all the different ways you are financially compensated by your employer: base salary, bonus, equity, benefits, etc. 10 | 11 | This calculator normalizes all these different forms of compensation into dollar values (from private or public companies) so you can estimate the final amount you are paid. 12 | 13 | ## Stack 14 | 15 | This project uses the following libraries and services: 16 | 17 | - Framework - [Remix](https://remix.run) 18 | - Styling - [Tailwind](https://tailwindcss.com) 19 | - API - [IEX](https://iexcloud.io/) 20 | - Hosting - [Cloudflare Pages](https://pages.cloudflare.com/) 21 | 22 | ## Development 23 | 24 | ```sh 25 | npm i 26 | # start the remix dev server and wrangler 27 | npm run dev 28 | ``` 29 | 30 | Open up [http://127.0.0.1:8788](http://127.0.0.1:8788) and you should be ready to go! 31 | -------------------------------------------------------------------------------- /app/components/About.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Portal } from "react-portal"; 3 | import type { 4 | CallBackProps, 5 | Step, 6 | Placement, 7 | TooltipRenderProps, 8 | } from "react-joyride"; 9 | import Joyride, { ACTIONS, EVENTS, STATUS } from "react-joyride"; 10 | 11 | const defaultStepProps = { 12 | disableBeacon: true, 13 | placement: "right" as Placement, 14 | floaterProps: { 15 | disableAnimation: true, 16 | }, 17 | }; 18 | 19 | export const defaultSteps: Step[] = [ 20 | { 21 | ...defaultStepProps, 22 | target: ".title-section", 23 | content: ( 24 | <> 25 |

26 | The term Total Compensation captures all the 27 | different ways you are financially compensated by your employer: base 28 | salary, bonus, equity, benefits, etc. 29 |

30 |

31 | This calculator normalizes all these different forms of compensation 32 | into dollar values (from private or public companies) so you can 33 | estimate the final amount you are paid. 34 |

35 | 36 | ), 37 | }, 38 | { 39 | ...defaultStepProps, 40 | target: ".cash-section", 41 | content: ( 42 | <> 43 |

44 | Cash compensation is the simplest category to understand because it's 45 | what gets directly deposited into your bank account. 46 |

47 |

48 | Types of cash compensation: 49 |

50 | 62 | 63 | ), 64 | }, 65 | { 66 | ...defaultStepProps, 67 | target: ".equity-section", 68 | content: ( 69 | <> 70 |

71 | Equity compensation is more complex, you only recieve during certain 72 | periods and it's difficult to get the exact dollar value of your 73 | equity. 74 |

75 |

76 | Types of equity compensation: 77 |

78 | 89 | 90 | ), 91 | }, 92 | { 93 | ...defaultStepProps, 94 | target: ".equity-value-section", 95 | content: ( 96 | <> 97 |

98 | Estimating the value of your equity is the hard part. Investors often 99 | look at value from multiple dimensions. To keep things simple, we 100 | offer 2 different approaches. 101 |

102 |

103 | Estimating equity value: 104 |

105 | 117 | 118 | ), 119 | }, 120 | { 121 | ...defaultStepProps, 122 | target: ".estimate-modal-button", 123 | content: ( 124 | <> 125 |

126 | If you don't know what numbers to use, we can offer reasonable 127 | defaults for you by looking at competitors. 128 |

129 | 130 | ), 131 | }, 132 | ]; 133 | 134 | export const useAbout = () => { 135 | const [run, setRun] = useState(false); 136 | const [stepIndex, setStepIndex] = useState(0); 137 | const [steps] = useState(defaultSteps); 138 | 139 | const handleJoyrideCallback = ({ 140 | action, 141 | index, 142 | type, 143 | status, 144 | }: CallBackProps) => { 145 | if ( 146 | action === ACTIONS.CLOSE || 147 | ([STATUS.FINISHED, STATUS.SKIPPED] as string[]).includes(status) 148 | ) { 149 | setRun(false); 150 | setStepIndex(0); 151 | window.scrollTo({ 152 | top: 0, 153 | left: 0, 154 | behavior: "smooth", 155 | }); 156 | } else if ( 157 | ([EVENTS.STEP_AFTER, EVENTS.TARGET_NOT_FOUND] as string[]).includes(type) 158 | ) { 159 | const stepIndex = index + (action === ACTIONS.PREV ? -1 : 1); 160 | setStepIndex(stepIndex); 161 | } 162 | }; 163 | 164 | return { 165 | run, 166 | setRun, 167 | stepIndex, 168 | setStepIndex, 169 | steps, 170 | handleJoyrideCallback, 171 | }; 172 | }; 173 | 174 | const Tooltip = ({ 175 | index, 176 | step, 177 | backProps, 178 | primaryProps, 179 | tooltipProps, 180 | isLastStep, 181 | }: TooltipRenderProps) => ( 182 |
186 | {step.title && ( 187 |

188 | {step.title} 189 |

190 | )} 191 | {step.content} 192 | 223 |
224 | ); 225 | 226 | type Props = ReturnType; 227 | 228 | export const About = ({ 229 | run, 230 | stepIndex, 231 | steps, 232 | handleJoyrideCallback, 233 | }: Props) => ( 234 | 235 | 248 | 249 | ); 250 | -------------------------------------------------------------------------------- /app/components/Chart.tsx: -------------------------------------------------------------------------------- 1 | import type { SeriesPoint } from "@visx/shape/lib/types"; 2 | import { BarStack } from "@visx/shape"; 3 | import { Group } from "@visx/group"; 4 | import { Grid } from "@visx/grid"; 5 | import { AxisBottom } from "@visx/axis"; 6 | import { scaleBand, scaleLinear, scaleOrdinal } from "@visx/scale"; 7 | import { useTooltip, useTooltipInPortal, defaultStyles } from "@visx/tooltip"; 8 | import { localPoint } from "@visx/event"; 9 | import NumberFormat from "react-number-format"; 10 | 11 | type CompTypes = "base" | "bonus" | "stock"; 12 | 13 | type Comp = { 14 | year: string; 15 | base: number; 16 | bonus: number; 17 | stock: number; 18 | }; 19 | 20 | type Data = Comp[]; 21 | 22 | type TooltipData = { 23 | bar: SeriesPoint; 24 | key: CompTypes; 25 | index: number; 26 | height: number; 27 | width: number; 28 | x: number; 29 | y: number; 30 | color: string; 31 | }; 32 | 33 | export type BarStackProps = { 34 | data: Data; 35 | width: number; 36 | height: number; 37 | margin?: { top: number; right: number; bottom: number; left: number }; 38 | }; 39 | 40 | const base = "#059669"; 41 | const bonus = "#0ea5e9"; 42 | const stock = "#f59e0b"; 43 | const defaultMargin = { top: 0, right: 0, bottom: 0, left: 0 }; 44 | const tooltipStyles = { 45 | ...defaultStyles, 46 | minWidth: 60, 47 | backgroundColor: "rgba(0,0,0,0.8)", 48 | borderRadius: "0.5rem", 49 | }; 50 | 51 | let tooltipTimeout: number; 52 | 53 | export default function Chart({ 54 | data, 55 | width, 56 | height, 57 | margin = defaultMargin, 58 | }: BarStackProps) { 59 | const { 60 | tooltipOpen, 61 | tooltipLeft, 62 | tooltipTop, 63 | tooltipData, 64 | hideTooltip, 65 | showTooltip, 66 | } = useTooltip(); 67 | 68 | const { containerRef, TooltipInPortal } = useTooltipInPortal({ 69 | // TooltipInPortal is rendered in a separate child of and positioned 70 | // with page coordinates which should be updated on scroll. consider using 71 | // Tooltip or TooltipWithBounds if you don't need to render inside a Portal 72 | scroll: true, 73 | }); 74 | 75 | const keys = Object.keys(data[0]).filter((d) => d !== "year") as CompTypes[]; 76 | 77 | const totals = data.reduce((all, current) => { 78 | const tc = keys.reduce((d, k) => { 79 | d += Number(current[k]); 80 | return d; 81 | }, 0); 82 | all.push(tc); 83 | return all; 84 | }, [] as number[]); 85 | 86 | const xScale = scaleBand({ 87 | domain: data.map((d) => d.year), 88 | padding: 0.2, 89 | }); 90 | 91 | const yScale = scaleLinear({ 92 | domain: [0, Math.max(...totals)], 93 | nice: true, 94 | }); 95 | 96 | const colorScale = scaleOrdinal({ 97 | domain: keys, 98 | range: [base, bonus, stock], 99 | }); 100 | 101 | // bounds 102 | const xMax = width; 103 | const yMax = height - margin.top - 100; 104 | 105 | xScale.rangeRound([0, xMax]); 106 | yScale.range([yMax, 0]); 107 | 108 | return ( 109 |
110 | 111 | 121 | 122 | d.year} 126 | xScale={xScale} 127 | yScale={yScale} 128 | color={colorScale} 129 | > 130 | {(barStacks) => 131 | barStacks.map((barStack) => 132 | barStack.bars.map((bar) => ( 133 | { 141 | tooltipTimeout = window.setTimeout(() => { 142 | hideTooltip(); 143 | }, 300); 144 | }} 145 | onMouseMove={(event) => { 146 | if (tooltipTimeout) clearTimeout(tooltipTimeout); 147 | const eventSvgCoords = localPoint(event); 148 | const left = bar.x + bar.width / 2; 149 | showTooltip({ 150 | tooltipData: bar, 151 | tooltipTop: eventSvgCoords?.y, 152 | tooltipLeft: left, 153 | }); 154 | }} 155 | /> 156 | )) 157 | ) 158 | } 159 | 160 | 161 | `Year ${d}`} 165 | stroke="#64748b" 166 | tickStroke="#64748b" 167 | tickLabelProps={() => ({ 168 | fill: "#64748b", 169 | fontSize: 10, 170 | textAnchor: "middle", 171 | })} 172 | /> 173 | 174 | {tooltipOpen && tooltipData && ( 175 | 180 |

181 | Year {tooltipData.bar.data["year"]} 182 |

183 |

184 | {tooltipData.key} compensation 185 |

186 | 194 |
195 | )} 196 |
197 | ); 198 | } 199 | -------------------------------------------------------------------------------- /app/components/CompForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { RadioGroup, Listbox } from "@headlessui/react"; 3 | import NumberFormat from "react-number-format"; 4 | import { FormField } from "~/components/FormField"; 5 | import { CompModal } from "~/components/CompModal"; 6 | import { useModal } from "~/components/Modal"; 7 | import type { CompHooksType } from "~/lib/comp"; 8 | import { 9 | currencyInputFormatProps, 10 | staticInputFormatProps, 11 | } from "~/lib/formProps"; 12 | 13 | type Props = { 14 | comp: CompHooksType; 15 | }; 16 | 17 | export const CompForm = ({ comp }: Props) => { 18 | const [shouldUpdate, setShouldUpdate] = useState(false); 19 | const modalProps = useModal(); 20 | 21 | useEffect(() => { 22 | if (shouldUpdate) { 23 | comp.updateData(); 24 | setShouldUpdate(false); 25 | } 26 | }, [comp, shouldUpdate]); 27 | 28 | return ( 29 | <> 30 |
31 |
32 | Cash Compensation 33 |
34 | 40 | comp.setBase(value)} 44 | onBlur={() => setShouldUpdate(true)} 45 | /> 46 | 47 | 53 | comp.setSignOnBonus(value)} 57 | onBlur={() => setShouldUpdate(true)} 58 | /> 59 | 60 | 66 | comp.setTargetBonus(value)} 70 | onBlur={() => setShouldUpdate(true)} 71 | /> 72 | 73 |
74 |
75 |
76 | 77 | Equity Compensation 78 | { 82 | comp.setShareType(shareType); 83 | comp.setIso(""); 84 | comp.setStrikePrice(""); 85 | comp.setRsu(""); 86 | comp.setExpectedGrowthMultiple(""); 87 | }} 88 | onBlur={() => setShouldUpdate(true)} 89 | > 90 | 91 | {({ checked }) => ( 92 | 97 | ISO 98 | 99 | )} 100 | 101 | 102 | {({ checked }) => ( 103 | 108 | RSU 109 | 110 | )} 111 | 112 | 113 | 114 | {comp.shareType === "iso" && ( 115 |
116 | 122 | comp.setIso(value)} 126 | onBlur={() => setShouldUpdate(true)} 127 | /> 128 | 129 | 135 | comp.setStrikePrice(value)} 139 | onBlur={() => setShouldUpdate(true)} 140 | /> 141 | 142 |
143 | )} 144 | {comp.shareType === "rsu" && ( 145 |
146 | 151 | comp.setRsu(value)} 155 | onBlur={() => setShouldUpdate(true)} 156 | /> 157 | 158 |
159 | )} 160 |
161 |
162 | 163 |
164 | Estimate Equity Value 165 | 186 |
187 | { 190 | comp.setShareCalcType(shareCalcType); 191 | comp.setPreferredSharePrice(""); 192 | comp.setExpectedGrowthMultiple(""); 193 | comp.setSharesOutstanding(""); 194 | comp.setExpectedRevenue(""); 195 | comp.setRevenueMultiple(""); 196 | }} 197 | > 198 |
199 | 200 | {comp.shareCalcType === "current" 201 | ? "Growth Based" 202 | : comp.shareCalcType === "revenue" 203 | ? "Revenue Based" 204 | : null} 205 | 206 | 207 | 210 | `cursor-pointer select-none relative py-2 px-4 ${ 211 | active ? "text-emerald-100 bg-emerald-900" : "" 212 | }` 213 | } 214 | > 215 | Growth Based 216 | 217 | 220 | `cursor-pointer select-none relative py-2 px-4 ${ 221 | active ? "text-emerald-100 bg-emerald-900" : "" 222 | }` 223 | } 224 | > 225 | Revenue Based 226 | 227 | 228 |
229 |
230 |
231 | {comp.shareCalcType === "current" && ( 232 |
233 | 243 | 247 | comp.setPreferredSharePrice(value) 248 | } 249 | onBlur={() => setShouldUpdate(true)} 250 | /> 251 | 252 | 262 | 268 | comp.setExpectedGrowthMultiple(value) 269 | } 270 | onBlur={() => setShouldUpdate(true)} 271 | /> 272 | 273 |
274 | )} 275 | {comp.shareCalcType === "revenue" && ( 276 |
277 | 283 | 287 | comp.setSharesOutstanding(value) 288 | } 289 | onBlur={() => setShouldUpdate(true)} 290 | /> 291 | 292 | 298 | comp.setExpectedRevenue(value)} 302 | onBlur={() => setShouldUpdate(true)} 303 | /> 304 | 305 | 311 | comp.setRevenueMultiple(value)} 315 | onBlur={() => setShouldUpdate(true)} 316 | /> 317 | 318 |
319 | )} 320 |
321 |
322 | 323 | 324 | ); 325 | }; 326 | -------------------------------------------------------------------------------- /app/components/CompModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useFetcher } from "@remix-run/react"; 3 | import Select, { components } from "react-select"; 4 | import NumberFormat from "react-number-format"; 5 | import { useDebouncedCallback } from "use-debounce"; 6 | import { Modal } from "~/components/Modal"; 7 | import { FormField } from "~/components/FormField"; 8 | import { 9 | currencyTextFormatProps, 10 | staticTextFormatProps, 11 | } from "~/lib/formProps"; 12 | 13 | import type { OptionProps, MultiValue } from "react-select"; 14 | import type { Props as ModalProps } from "~/components/Modal"; 15 | import type { CompHooksType } from "~/lib/comp"; 16 | 17 | type Props = { setShouldUpdate: (t: boolean) => void } & CompHooksType & 18 | Omit; 19 | 20 | const Option = ({ children, ...rest }: OptionProps) => { 21 | return ( 22 | 23 | 24 | {rest.data.symbol} 25 | 26 | {children} 27 | 28 | ); 29 | }; 30 | 31 | export const CompModal = ({ 32 | isOpen, 33 | closeModal, 34 | openModal, 35 | shareType, 36 | shareCalcType, 37 | setExpectedGrowthMultiple, 38 | setPreferredSharePrice, 39 | setSharesOutstanding, 40 | setExpectedRevenue, 41 | setRevenueMultiple, 42 | setShouldUpdate, 43 | }: Props) => { 44 | const [view, setView] = useState("estimate"); 45 | const search = useFetcher(); 46 | const companies = useFetcher(); 47 | 48 | const loadOptions = useDebouncedCallback((value) => { 49 | search.load(`/api/search?q=${value}`); 50 | }, 300); 51 | 52 | const loadCompaniesData = (selected: MultiValue) => { 53 | const query = selected.map((s) => s.symbol).join(","); 54 | companies.load(`/api/stock?s=${query}`); 55 | }; 56 | 57 | const handleClose = () => { 58 | loadCompaniesData([]); 59 | closeModal(); 60 | }; 61 | 62 | const handleUse = (c: any) => { 63 | if (isoCurrent) { 64 | setExpectedGrowthMultiple(c.year5ChangePercent.toFixed(2)); 65 | } 66 | if (rsuCurrent) { 67 | setPreferredSharePrice((c.marketcap / c.sharesOutstanding).toFixed(2)); 68 | setExpectedGrowthMultiple(((c.year5ChangePercent / 5) * 100).toFixed(2)); 69 | } 70 | if (isoRevenue || rsuRevenue) { 71 | setSharesOutstanding(c.sharesOutstanding.toString()); 72 | setExpectedRevenue(c.revenue.toString()); 73 | setRevenueMultiple(c.revenuePerShare.toString()); 74 | } 75 | setShouldUpdate(true); 76 | handleClose(); 77 | }; 78 | 79 | const isoCurrent = shareType === "iso" && shareCalcType == "current"; 80 | const rsuCurrent = shareType === "rsu" && shareCalcType == "current"; 81 | const isoRevenue = shareType === "iso" && shareCalcType == "revenue"; 82 | const rsuRevenue = shareType === "rsu" && shareCalcType == "revenue"; 83 | 84 | return ( 85 | 91 |

92 | {view === "estimate" ? "Estimate Equity Value" : "Terminology"} 93 |

94 | 95 | 123 | 145 | 146 | 147 | } 148 | > 149 | {view === "terminology" && ( 150 |
151 | {isoCurrent && ( 152 |
153 |

Preferred Stock Price

154 |

155 | The preferred stock price is the price at which investors 156 | currently pay for shares of the company. You can ask your 157 | recruiter what the current price is. 158 |

159 |
160 | )} 161 | {rsuCurrent && ( 162 |
163 |

Current Market Price

164 |

165 | This is the stock price at which the company is currently 166 | trading 167 |

168 |
169 | )} 170 | {(isoRevenue || rsuRevenue) && ( 171 |
172 |

Shares Outstanding

173 |

174 | The shares outstanding is the number of shares that the company 175 | has available in the market. 176 |

177 |
178 | )} 179 |
180 | {isoCurrent && ( 181 | <> 182 |

183 | Expected Growth over 4 years 184 |

185 |

186 | Depending on the stage of the company expected growth can 187 | vary. Investors typically expect a 10x return on what they put 188 | in. 189 |

190 | 191 | )} 192 | {rsuCurrent && ( 193 | <> 194 |

195 | Expected Market Growth 196 |

197 |

198 | How much do you expect the stock price to change every year? 199 | Anualized growth over the last 4 years is a good estimate. 200 |

201 | 202 | )} 203 | {(isoRevenue || rsuRevenue) && ( 204 | <> 205 |

206 | Expected Company Revenue 207 |

208 |

209 | How much do you expect the company to make every year? Divide 210 | this number by the number of shares outstanding to get the 211 | revenue multiple. 212 |

213 |

214 | Revenue Multiple 215 |

216 |

217 | The revenue multiple is the ratio of the company's revenue 218 | relative to its stock price. You can use your competitors 219 | revenue multiple to estimate what your share value would be. 220 |

221 | 222 | )} 223 |
224 |
225 | )} 226 | {view === "estimate" && ( 227 |
228 |
229 |

230 | Estimate reasonable numbers for your equity value by looking at 231 | competitors: 232 |

233 |
234 | 240 | 31 | ); 32 | 33 | return ( 34 |
37 | 43 | {field} 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /app/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export const Logo = () => ( 2 | 3 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /app/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Transition } from "@headlessui/react"; 2 | import { Fragment, useState } from "react"; 3 | 4 | export const useModal = () => { 5 | const [isOpen, setIsOpen] = useState(false); 6 | 7 | const closeModal = () => { 8 | setIsOpen(false); 9 | }; 10 | 11 | const openModal = () => { 12 | setIsOpen(true); 13 | }; 14 | 15 | return { isOpen, closeModal, openModal }; 16 | }; 17 | 18 | export type Props = { 19 | title?: React.ReactNode; 20 | children: React.ReactNode; 21 | } & ReturnType; 22 | 23 | export const Modal = ({ isOpen, closeModal, title, children }: Props) => { 24 | return ( 25 | 26 | 27 | 36 |
37 | 38 | 39 |
40 |
41 | 50 | 51 | {title && ( 52 | 56 | {title} 57 | 58 | )} 59 | {children} 60 | 61 | 62 |
63 |
64 |
65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /app/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "~/components/Logo"; 2 | import { About, useAbout } from "~/components/About"; 3 | 4 | export const Navigation = () => { 5 | const aboutProps = useAbout(); 6 | 7 | return ( 8 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "@remix-run/react"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import type { EntryContext } from "@remix-run/cloudflare"; 3 | import { RemixServer } from "@remix-run/react"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/global.css: -------------------------------------------------------------------------------- 1 | div.react-select__control, 2 | div.react-select__control:hover, 3 | div.react-select__control.react-select__control--is-focused { 4 | background-color: transparent; 5 | border: none; 6 | box-shadow: none; 7 | } 8 | 9 | div.react-select__value-container { 10 | padding: 0; 11 | } 12 | 13 | div.react-select__input-container { 14 | color: #10b981; 15 | margin: 0; 16 | padding: 0; 17 | } 18 | 19 | input.react-select__input:focus { 20 | box-shadow: none; 21 | } 22 | 23 | div.react-select__value-container { 24 | gap: 2px; 25 | } 26 | 27 | div.react-select__multi-value { 28 | background-color: #1e293b; 29 | } 30 | 31 | div.react-select__multi-value__label { 32 | color: #cbd5e1; 33 | } 34 | 35 | div.react-select__multi-value__remove:hover { 36 | background-color: transparent; 37 | } 38 | 39 | div.react-select__menu { 40 | margin: 0; 41 | background-color: #1e293b; 42 | border: 1px solid #475569; 43 | } 44 | 45 | .react-select__option.react-select__option--is-focused { 46 | background-color: #0f172a; 47 | } 48 | -------------------------------------------------------------------------------- /app/lib/comp.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const baseData = [ 4 | { 5 | year: "1", 6 | base: 0, 7 | bonus: 0, 8 | stock: 0, 9 | }, 10 | { 11 | year: "2", 12 | base: 0, 13 | bonus: 0, 14 | stock: 0, 15 | }, 16 | { 17 | year: "3", 18 | base: 0, 19 | bonus: 0, 20 | stock: 0, 21 | }, 22 | { 23 | year: "4", 24 | base: 0, 25 | bonus: 0, 26 | stock: 0, 27 | }, 28 | ]; 29 | 30 | export type BaseDataType = typeof baseData; 31 | 32 | const calculateBase = (base = "0") => { 33 | return parseFloat(base || "0"); 34 | }; 35 | 36 | const calculateBonus = (year = "1", signOnBonus = "0", targetBonus = "0") => { 37 | if (year === "1") 38 | return parseFloat(signOnBonus || "0") + parseFloat(targetBonus || "0"); 39 | return parseFloat(targetBonus || "0"); 40 | }; 41 | 42 | const calculateStocks = (shares = "0", strikePrice = "0", shareValue = "0") => { 43 | const total = 44 | parseFloat(shares || "0") * parseFloat(shareValue || "0") - 45 | parseFloat(shares || "0") * parseFloat(strikePrice || "0"); 46 | return total > 0 ? total : 0; 47 | }; 48 | 49 | const compoundInterest = ( 50 | principle: number = 0, 51 | rate: number = 0, 52 | time: number = 1, 53 | n: number = 1 54 | ) => { 55 | const amount = principle * Math.pow(1 + rate / n, n * time); 56 | const interest = amount - principle; 57 | return interest; 58 | }; 59 | 60 | const calculateShareValueFromMultiple = ( 61 | preferredSharePrice = "0", 62 | multiple = "0", 63 | calcTotal = true, 64 | year = "1" 65 | ) => { 66 | const price = parseFloat(preferredSharePrice || "0"); 67 | const rate = parseFloat(multiple || "0"); 68 | 69 | if (calcTotal) return (price * rate || 1).toFixed(2); 70 | 71 | const interest = compoundInterest(price, rate / 100, parseInt(year)); 72 | return (price + interest).toFixed(2); 73 | }; 74 | 75 | const calculateShareValueFromRevenue = ( 76 | sharesOutstanding = "0", 77 | expectedRevenue = "0", 78 | revenueMultiple = "0" 79 | ) => { 80 | const valuation = 81 | parseFloat(expectedRevenue || "0") * parseFloat(revenueMultiple || "0"); 82 | const shareValue = valuation / parseInt(sharesOutstanding || "1"); 83 | 84 | return shareValue.toFixed(2); 85 | }; 86 | 87 | export const useCompHooks = () => { 88 | const [data, setData] = useState(baseData); 89 | 90 | // cash comp 91 | const [base, setBase] = useState(""); 92 | const [signOnBonus, setSignOnBonus] = useState(""); 93 | const [targetBonus, setTargetBonus] = useState(""); 94 | 95 | // stock info 96 | const [shareType, setShareType] = useState("iso"); 97 | const [iso, setIso] = useState(""); 98 | const [strikePrice, setStrikePrice] = useState(""); 99 | const [rsu, setRsu] = useState(""); 100 | 101 | // stock comp 102 | const [shareCalcType, setShareCalcType] = useState("current"); 103 | // common 104 | const [preferredSharePrice, setPreferredSharePrice] = useState(""); 105 | const [expectedGrowthMultiple, setExpectedGrowthMultiple] = useState(""); 106 | // revenue 107 | const [sharesOutstanding, setSharesOutstanding] = useState(""); 108 | const [expectedRevenue, setExpectedRevenue] = useState(""); 109 | const [revenueMultiple, setRevenueMultiple] = useState(""); 110 | 111 | const updateData = () => { 112 | setData((prevData) => 113 | prevData.map((d) => { 114 | const sv = 115 | shareCalcType === "current" 116 | ? calculateShareValueFromMultiple( 117 | preferredSharePrice, 118 | expectedGrowthMultiple, 119 | shareType === "iso", 120 | d.year 121 | ) 122 | : calculateShareValueFromRevenue( 123 | sharesOutstanding, 124 | expectedRevenue, 125 | revenueMultiple 126 | ); 127 | 128 | return { 129 | ...d, 130 | base: calculateBase(base), 131 | bonus: calculateBonus(d.year, signOnBonus, targetBonus), 132 | stock: 133 | shareType === "iso" 134 | ? calculateStocks(iso, strikePrice, sv) 135 | : calculateStocks(rsu, "0", sv), 136 | }; 137 | }) 138 | ); 139 | }; 140 | 141 | return { 142 | data, 143 | updateData, 144 | base, 145 | setBase, 146 | signOnBonus, 147 | setSignOnBonus, 148 | targetBonus, 149 | setTargetBonus, 150 | 151 | shareType, 152 | setShareType, 153 | 154 | iso, 155 | setIso, 156 | rsu, 157 | setRsu, 158 | strikePrice, 159 | setStrikePrice, 160 | 161 | shareCalcType, 162 | setShareCalcType, 163 | 164 | preferredSharePrice, 165 | setPreferredSharePrice, 166 | expectedGrowthMultiple, 167 | setExpectedGrowthMultiple, 168 | 169 | sharesOutstanding, 170 | setSharesOutstanding, 171 | expectedRevenue, 172 | setExpectedRevenue, 173 | revenueMultiple, 174 | setRevenueMultiple, 175 | }; 176 | }; 177 | 178 | export type CompHooksType = ReturnType; 179 | -------------------------------------------------------------------------------- /app/lib/formProps.ts: -------------------------------------------------------------------------------- 1 | export const staticInputFormatProps = { 2 | displayType: "input" as "input", 3 | thousandSeparator: true, 4 | isNumericString: true, 5 | allowNegative: false, 6 | }; 7 | 8 | export const staticTextFormatProps = { 9 | decimalSeparator: ".", 10 | displayType: "text" as "text", 11 | thousandSeparator: true, 12 | isNumericString: true, 13 | allowNegative: false, 14 | decimalScale: 2, 15 | fixedDecimalScale: true, 16 | }; 17 | 18 | export const currencyInputFormatProps = { 19 | prefix: "$", 20 | decimalSeparator: ".", 21 | displayType: "input" as "input", 22 | thousandSeparator: true, 23 | isNumericString: true, 24 | allowNegative: false, 25 | }; 26 | 27 | export const currencyTextFormatProps = { 28 | prefix: "$", 29 | decimalSeparator: ".", 30 | displayType: "text" as "text", 31 | thousandSeparator: true, 32 | isNumericString: true, 33 | allowNegative: false, 34 | decimalScale: 2, 35 | fixedDecimalScale: true, 36 | }; 37 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from "@remix-run/cloudflare"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react"; 10 | import styles from "./tailwind.css"; 11 | import global from "./global.css"; 12 | 13 | export const meta: MetaFunction = () => ({ 14 | title: "Total Compensation Calculator", 15 | charset: "utf-8", 16 | viewport: "width=device-width,initial-scale=1", 17 | "msapplication-TileColor": "#da532c", 18 | "msapplication-config": "/favicon/browserconfig.xml", 19 | "theme-color": "#ffffff", 20 | }); 21 | 22 | export const links: LinksFunction = () => [ 23 | { rel: "stylesheet", href: styles }, 24 | { rel: "stylesheet", href: global }, 25 | { 26 | rel: "apple-touch-icon", 27 | sizes: "180x180", 28 | href: "/favicon/apple-touch-icon.png", 29 | }, 30 | { 31 | rel: "icon", 32 | type: "image/png", 33 | sizes: "32x32", 34 | href: "/favicon/favicon-32x32.png", 35 | }, 36 | { 37 | rel: "icon", 38 | type: "image/png", 39 | sizes: "16x16", 40 | href: "/favicon/favicon-16x16.png", 41 | }, 42 | { rel: "manifest", href: "/favicon/site.webmanifest" }, 43 | { 44 | rel: "mask-icon", 45 | href: "/favicon/safari-pinned-tab.svg", 46 | color: "#555555", 47 | }, 48 | { rel: "shortcut icon", href: "/favicon/favicon.ico" }, 49 | { rel: "canonical", href: "https://tc.kyh.io" }, 50 | ]; 51 | 52 | export default function App() { 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |