diff --git a/_posts/2022-01-11-substantial-presence-test/index.mdx b/_posts/2022-01-11-substantial-presence-test/index.mdx index e61cd8b..9eb31c9 100644 --- a/_posts/2022-01-11-substantial-presence-test/index.mdx +++ b/_posts/2022-01-11-substantial-presence-test/index.mdx @@ -4,8 +4,23 @@ date: 2022-01-11 status: draft --- -The substantial presence test can be a bit confusing. Here's a simple calculator to help you determine whether you pass. +The IRS uses the [Substantial Presence +Test](https://www.irs.gov/individuals/international-taxpayers/substantial-presence-test) to determine whether +a person is a resident for tax purposes. As of the time this calculator was built, the requirements for the +test are that the person must be physically present in the United States on at lesat: + +1. 31 days during the current year, and +2. 183 days during the 3-year period that includes the current year and the 2 years immediately before that, + counting: + - All the days you were present in the current year, and + - 1/3 of the days you were present in the first year before the current year, and + - 1/6 of the days you were present in the second year before the current year. + +This can be a bit confusing to calculate, so I've created a tool to help. Your changes will be automatically +saved to your browser so you can come back later on the same device and get an updated answer if necessary.
+ +
diff --git a/components/MdxRenderer.tsx b/components/MdxRenderer.tsx index 12b61e2..42ea3d9 100644 --- a/components/MdxRenderer.tsx +++ b/components/MdxRenderer.tsx @@ -21,7 +21,9 @@ const shortcodes = { h4: createHeadingComponent('h4'), h5: createHeadingComponent('h5'), h6: createHeadingComponent('h6'), - SubstantialPresenceTest, // TODO: Should be able to import from just the article that uses this + + // TODO: Should be able to import these from just the articles that uses them + SubstantialPresenceTest, RocketLeague, }; diff --git a/components/SubstantialPresenceTest.tsx b/components/SubstantialPresenceTest.tsx index 1df65dd..71f6238 100644 --- a/components/SubstantialPresenceTest.tsx +++ b/components/SubstantialPresenceTest.tsx @@ -1,313 +1,394 @@ -import { Fragment, useEffect, useReducer, useState } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { FC } from 'react'; +import { addDays, differenceInDays } from 'date-fns'; -const NUM_YEARS_TO_INCLUDE = 10; const CURRENT_YEAR = new Date().getFullYear(); const TOTAL_REQUIREMENT = 183; -const YEAR_REQUIREMENTS = [ - { - yearOffset: 0, - minimum: 31, - multiplier: 1, - }, - { - yearOffset: -1, - multiplier: 1 / 3, - }, - { - yearOffset: -2, - multiplier: 1 / 6, - }, -]; - -function dateToValue(date: Date) { - return date.toISOString().split('T')[0]; +const CURRENT_YEAR_REQUIREMENT = 31; +const YEAR_MULTIPLIERS = [1, 1 / 3, 1 / 6]; +const NUM_YEARS_TO_INCLUDE = YEAR_MULTIPLIERS.length * 4; + +interface Range { + start: number; + end: number; } -function addDays(date: Date, days: number) { - const newDate = new Date(date); - newDate.setDate(newDate.getDate() + days); - return newDate; +interface YearDetails { + present: boolean; + ranges: Range[]; } -type DayCounterRangeProps = { - year: number; - onChange?: (range: DayCounterRange) => void; -}; +const yearAtom = atomWithStorage( + 'substantialPresenceTest:selectedYear', + CURRENT_YEAR +); +const yearsDetailsAtom = atomWithStorage>( + 'substantialPresenceTest:yearsDetails', + {} +); -const DayCounterRange = function ({ year, onChange }: DayCounterRangeProps) { - const min = `${year}-01-01`; - const max = year !== CURRENT_YEAR ? `${year}-12-31` : dateToValue(new Date()); +function getAllValidDays( + year: number, + yearsDetails: Record +) { + return YEAR_MULTIPLIERS.map((multiplier, i) => { + const offsetYear = year - i; + return getValidDays(multiplier, yearsDetails[offsetYear]); + }).reduce( + (acc, curr) => ({ + totalDays: acc.totalDays + curr.validDays, + validDays: acc.validDays + curr.validDays, + lastDay: Math.max(curr.lastDay ?? 0, acc.lastDay ?? 0), + }), + { totalDays: 0, validDays: 0, lastDay: 0 } + ); +} - const [start, setStart] = useState(new Date(min)); - const [end, setEnd] = useState(new Date(max)); +function getValidDays(multiplier: number, details?: YearDetails) { + const totalDays = details?.present + ? details.ranges.reduce( + (total, range) => total + differenceInDays(range.end, range.start) + 1, + 0 + ) + : 0; + const validDays = Math.floor(totalDays * multiplier); + return { totalDays, validDays, lastDay: details?.ranges.at(-1)?.end }; +} - function computeChange() { - if (onChange) { - const timeDiff = end.getTime() - start.getTime(); - const daysDiff = timeDiff / (1000 * 3600 * 24); - onChange({ count: daysDiff, start, end }); // TODO: Should we include the last day? - } - } +const SubstantialPresenceTest = () => { + const year = useAtomValue(yearAtom); + const yearsDetails = useAtomValue(yearsDetailsAtom); - useEffect(() => { - computeChange(); - }, [start, end]); + const { validDays, lastDay } = getAllValidDays(year, yearsDetails); + const { validDays: currentYearValidDays } = getValidDays( + YEAR_MULTIPLIERS[0], + yearsDetails[year] + ); - function handleChange(setter: (date: Date) => void, value: Date) { - setter(value); - } + const dayOfPassing = + validDays >= TOTAL_REQUIREMENT + ? currentYearValidDays > CURRENT_YEAR_REQUIREMENT + ? new Date(lastDay!) + : addDays(lastDay!, CURRENT_YEAR_REQUIREMENT - currentYearValidDays) + : addDays(lastDay!, TOTAL_REQUIREMENT - validDays); return ( - - {' '} - - +
+

Substantial Presence Test Calculator

+ + + + + + + + + + + + + + + + + + +
Total eligible days for the current year + {currentYearValidDays} / {CURRENT_YEAR_REQUIREMENT} +
Total eligible days + {validDays} / {TOTAL_REQUIREMENT} +
+ +
+ {validDays >= TOTAL_REQUIREMENT && + currentYearValidDays >= CURRENT_YEAR_REQUIREMENT ? ( + <> + You have passed the Substantial Presence Test for{' '} + {year}! + + ) : dayOfPassing.getFullYear() === year ? ( + <> + If you remain in the country, you will pass the Substantial Presence + Test on{' '} + + . + + ) : ( + <> + You cannot pass the Substantial Presence Test in{' '} + {year}. + + )} +
+
); }; -type DayCounterRange = { - count: number; - start: Date | null; - end: Date | null; +const YearPicker = () => { + const [year, setYear] = useAtom(yearAtom); + + return ( + + ); }; -type DayCounterState = { - present: boolean; - ranges: DayCounterRange[]; - count: number; +const YearCounterRows = () => { + return ( + <> + {YEAR_MULTIPLIERS.map((multiplier, i) => { + return ; + })} + + ); }; -type DayCounterAction = - | { type: 'togglePresent' } - | { type: 'addRange' } - | { type: 'removeRange'; range: DayCounterRange } - | { type: 'updateRange'; range: DayCounterRange; updates: DayCounterRange }; - -function dayCounterReducer( - state: DayCounterState, - action: DayCounterAction -): DayCounterState { - function primaryReducer() { - if (action.type === 'togglePresent') { - return { ...state, present: !state.present }; - } else if (action.type === 'addRange') { - return { - ...state, - ranges: [...state.ranges, { count: 0, start: null, end: null }], - }; - } else if (action.type === 'removeRange') { - return { - ...state, - ranges: state.ranges.filter((x) => x !== action.range), - }; - } else if (action.type === 'updateRange') { - return { - ...state, - ranges: state.ranges.map((x) => - x !== action.range ? x : { ...x, ...action.updates } - ), - }; - } - throw new Error(); - } - - const nextState = primaryReducer(); - return { - ...nextState, - count: !nextState.present - ? 0 - : nextState.ranges.reduce((count, range) => count + range.count, 0), - }; +function startOfYear(year: number) { + return new Date(`${year}-01-01`).getTime(); } -type DayCounterProps = { - year: number; - onChange?: (count: number) => void; -}; +function endOfYear(year: number) { + return new Date(`${year}-12-31`).getTime(); +} + +function functionalEndOfYear(year: number) { + return Math.min(Date.now(), endOfYear(year)); +} + +interface YearCounterProps { + yearOffset: number; + multiplier: number; +} + +const YearCounter: FC = ({ yearOffset, multiplier }) => { + const year = useAtomValue(yearAtom) - yearOffset; -const DayCounter = function ({ year, onChange }: DayCounterProps) { - const [state, dispatch] = useReducer(dayCounterReducer, { + const [yearsDetails, setYearsDetails] = useAtom(yearsDetailsAtom); + const details = yearsDetails[year] ?? { present: false, - ranges: [{ count: 0, start: null, end: null }], - count: 0, - }); + ranges: [ + { + start: startOfYear(year), + end: functionalEndOfYear(year), + }, + ], + }; - useEffect(() => { - if (onChange) { - onChange(state.count); - } - }, [state.count]); + const { totalDays, validDays } = getValidDays(multiplier, details); return ( -
- - - {state.present ? ( - <> - {state.ranges.map((range, i) => ( -
- - {i !== 0 ? and : null} - - dispatch({ type: 'updateRange', range, updates }) - } - /> - {' '} - -
- ))} + )} + + + + ); +}; + +interface YearCounterRangesProps { + year: number; + yearDetails: YearDetails; + addRange: () => void; + removeRange: (i: number) => void; + setRange: (i: number, range: Range) => void; +} + +const YearCounterRanges: FC = ({ + year, + yearDetails, + addRange, + removeRange, + setRange, +}) => { + return ( + <> + {yearDetails.ranges.map((range, i) => { + return ( + removeRange(i)} + setStart={(start) => setRange(i, { ...range, start })} + setEnd={(end) => setRange(i, { ...range, end })} + /> + ); + })} + + + - - ) : null} -
+ + + ); }; -const SubstantialPresenceTest = function () { - function getDefaultDayCounts(year: number) { - const dayCounts: Record = {}; - YEAR_REQUIREMENTS.forEach((req) => (dayCounts[year + req.yearOffset] = 0)); - return dayCounts; - } - - const [year, setYear] = useState(CURRENT_YEAR); - const [dayCounts, setDayCounts] = useState>( - getDefaultDayCounts(CURRENT_YEAR) - ); - - const total = YEAR_REQUIREMENTS.reduce( - (sum, req) => - sum + Math.floor(dayCounts[year + req.yearOffset] * req.multiplier), - 0 - ); +function dateString(datetime: number | Date) { + return new Date(datetime).toISOString().split('T')[0]; +} - const currentDate = new Date(); - const passingDate = new Date( - currentDate.setDate(currentDate.getDate() + (TOTAL_REQUIREMENT - total)) - ); +interface YearCounterRangeProps { + year: number; + first?: boolean; + lastRemaining: boolean; + previousRange?: YearDetails['ranges'][number]; + range: YearDetails['ranges'][number]; + removeRange: () => void; + setStart: (start: number) => void; + setEnd: (end: number) => void; +} +const YearCounterRange: FC = ({ + year, + first = false, + lastRemaining, + previousRange, + range, + removeRange, + setStart, + setEnd, +}) => { return ( -
-

Substantial Presence Test Calculator

- -
+ x + + + ); }; diff --git a/package.json b/package.json index a5a451f..fccb84a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "dependencies": { "@types/remark-prism": "^1.3.0", "clsx": "^1.1.1", + "date-fns": "^2.28.0", "gray-matter": "^4.0.3", + "jotai": "^1.6.7", "next": "12.0.9", "next-mdx-remote": "^3.0.8", "prism-theme-night-owl": "^1.4.0", diff --git a/styles/Layout.module.css b/styles/Layout.module.css index 1a1545c..2dd2ce3 100644 --- a/styles/Layout.module.css +++ b/styles/Layout.module.css @@ -26,7 +26,7 @@ padding-top: 0; } -.main time { +.main > time { opacity: 0.7; } diff --git a/tsconfig.json b/tsconfig.json index 99710e8..0f293bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,8 @@ "allowJs": true, "skipLibCheck": true, "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, diff --git a/yarn.lock b/yarn.lock index b5a9d24..979e11a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -880,6 +880,11 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" +date-fns@^2.28.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" + integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: version "4.3.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" @@ -1809,6 +1814,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +jotai@^1.6.7: + version "1.6.7" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.7.tgz#d5bdc747b66e369f1d1e859dad8bcda85acb946f" + integrity sha512-MxAm86FYHm6asU3O1hbTKOWpPMP5oI8zNlgFRpZBB85cX79psF2eRb7ZqdGewOgYUUUKAlzZ+sH0IM7Pea6MkA== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"