diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e09c54e..6cec2e6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,50 +7,119 @@ on: workflow_dispatch: jobs: - test-app: + setup-environment: + name: Setup environment runs-on: ubuntu-latest env: FIREBASE_EMULATORS_PATH: ${{ github.workspace }}/emulator-cache + CYPRESS_CACHE_FOLDER: ~/.cache/Cypress steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install pnpm - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v4 with: version: 10.13.1 run_install: false - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 22 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile # optional, --immutable + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' - - name: Start firebase in background - run: pnpm firebase & + - name: Determine pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - # - name: sleep for 30 seconds - # run: sleep 30 - # shell: bash + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- - # - name: Cache firebase emulators - # uses: actions/cache@v3 - # with: - # path: ${{ env.FIREBASE_EMULATORS_PATH }} - # key: - # ${{ runner.os }}-firebase-emulators-${{ hashFiles('emulator-cache/**') }} - # continue-on-error: true + - name: Cache Cypress binary + uses: actions/cache@v4 + with: + path: ~/.cache/Cypress + key: cypress-binary-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + cypress-binary-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile # optional, --immutable + + run-tests: + name: Run E2E tests + runs-on: ubuntu-latest + needs: setup-environment + env: + FIREBASE_EMULATORS_PATH: ${{ github.workspace }}/emulator-cache + CYPRESS_CACHE_FOLDER: ~/.cache/Cypress + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.13.1 + run_install: false + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Determine pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Cypress binary + uses: actions/cache@v4 + with: + path: ~/.cache/Cypress + key: cypress-binary-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + cypress-binary-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile # optional, --immutable + + - name: Start firebase in background + run: pnpm firebase & - name: Start app and run tests uses: cypress-io/github-action@v4 with: build: pnpm build start: pnpm start - wait-on: 'http://localhost:8082, http://localhost:8081' + wait-on: 'http://localhost:8082, http://localhost:8081, http://localhost:3000' + install: false env: FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f1bd7d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "[javascript, typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json, jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, +} diff --git a/biome.json b/biome.json index 4afb514..ab70ae5 100644 --- a/biome.json +++ b/biome.json @@ -12,6 +12,8 @@ "**/*.tsx", "**/*.js", "**/*.jsx", + "**/*.css", + "**/*.cy.ts", "!**/*.min.js", "!**/node_modules", "!**/dist", @@ -22,8 +24,7 @@ "!**/.husky", "!**/.vscode", "!**/.env", - "!**/.env.local", - "!**/cypress" + "!**/.env.local" ] }, "formatter": { @@ -34,7 +35,16 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "performance": { + "noImgElement": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, + "correctness": { + "useExhaustiveDependencies": "warn" + } } }, "javascript": { @@ -45,11 +55,16 @@ "trailingCommas": "es5" } }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, "assist": { "enabled": true, "actions": { "source": { - "organizeImports": "off" + "organizeImports": "on" } } } diff --git a/components/blog/blogFooter.tsx b/components/blog/blogFooter.tsx deleted file mode 100644 index f7f25a0..0000000 --- a/components/blog/blogFooter.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Text, Image, Spacer, Grid, Button } from '@geist-ui/react' -import { useRouter } from 'next/router' - -import { useAuth } from 'lib/auth' - -export default function BlogFooter(): JSX.Element { - const { user, loading } = useAuth() - - const router = useRouter() - return ( - - - - Designed and developed by Michael Schultz in Oakland, California. - - {user ? ( - Thanks for being part of the Hemolog community! - ) : ( - <> - Start using Hemolog for free. - - - )} - - - - Michael Schultz - - - ) -} diff --git a/components/blog/postFooter.tsx b/components/blog/postFooter.tsx deleted file mode 100644 index c69c834..0000000 --- a/components/blog/postFooter.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { - Spacer, - Grid, - User, - Divider, - useClipboard, - useToasts, -} from '@geist-ui/react' -import { Share } from '@geist-ui/react-icons' - -export default function PostFooter({ postId }: { postId: string }) { - const [, setToast] = useToasts() - const { copy } = useClipboard() - const handleCopy = (postId: string) => { - copy(`https://hemolog.com/changelog#${postId}`) - setToast({ type: 'success', text: 'Link copied!' }) - } - - return ( - <> - - - - - - @michaelschultz - - - - -
- handleCopy(postId)} /> -
-
-
- - - - ) -} diff --git a/components/chart.tsx b/components/chart.tsx deleted file mode 100644 index 58ac1c7..0000000 --- a/components/chart.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { - FlexibleWidthXYPlot, - VerticalBarSeries, - HorizontalGridLines, - XAxis, - YAxis, -} from 'react-vis' -import useInfusions from 'lib/hooks/useInfusions' -import { filterInfusions } from 'lib/helpers' -import { TreatmentTypeEnum } from 'lib/db/infusions' - -// Dynamically load react-vis CSS only when Chart component is used -if (typeof window !== 'undefined') { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = 'https://unpkg.com/react-vis/dist/style.css' - if (!document.querySelector(`link[href="${link.href}"]`)) { - document.head.appendChild(link) - } -} - -type ChartEntry = { - x: string - y: number -} - -interface ChartProps { - filterYear: string -} - -export default function Chart(props: ChartProps): JSX.Element | null { - const { filterYear } = props - const { data } = useInfusions() - - if (!data) { - return null - } - - const filteredInfusions = filterInfusions(data, filterYear) - - const bleeds = filteredInfusions - .filter((entry) => entry.type === TreatmentTypeEnum.BLEED) - .map((bleed) => bleed.date) - - const preventative = filteredInfusions - .filter((entry) => entry.type === TreatmentTypeEnum.PREVENTATIVE) - .map((preventitive) => preventitive.date) - - const prophy = filteredInfusions - .filter((entry) => entry.type === TreatmentTypeEnum.PROPHY) - .map((prophy) => prophy.date) - - const antibody = filteredInfusions - .filter((entry) => entry.type === TreatmentTypeEnum.ANTIBODY) - .map((antibody) => antibody.date) - - const chartSchema: ChartEntry[] = [ - { x: 'Jan', y: 0 }, - { x: 'Feb', y: 0 }, - { x: 'Mar', y: 0 }, - { x: 'Apr', y: 0 }, - { x: 'May', y: 0 }, - { x: 'Jun', y: 0 }, - { x: 'Jul', y: 0 }, - { x: 'Aug', y: 0 }, - { x: 'Sep', y: 0 }, - { x: 'Oct', y: 0 }, - { x: 'Nov', y: 0 }, - { x: 'Dec', y: 0 }, - ] - - // clones array using value rather than reference - const bleedData = JSON.parse(JSON.stringify(chartSchema)) - const preventativeData = JSON.parse(JSON.stringify(chartSchema)) - const prophyData = JSON.parse(JSON.stringify(chartSchema)) - const antibodyData = JSON.parse(JSON.stringify(chartSchema)) - - // distribute infusions into months - const distributeInfusions = (infusions: string[], data: ChartEntry[]) => { - for (const infusion of infusions) { - // Extract month from YYYY-MM-DD format (zero-based index) - const monthIndex = Number.parseInt(infusion.split('-')[1], 10) - 1 - data[monthIndex].y = data[monthIndex].y + 1 - } - } - - distributeInfusions(bleeds, bleedData) - distributeInfusions(preventative, preventativeData) - distributeInfusions(prophy, prophyData) - distributeInfusions(antibody, antibodyData) - - // determine the highest number of grouped infunsions to - // create a max value used to set the height of the chart - const bleedNumbers = bleedData.map((infusion: ChartEntry) => infusion.y) - const preventativeNumbers = preventativeData.map( - (infusion: ChartEntry) => infusion.y - ) - const prophyNumbers = prophyData.map((infusion: ChartEntry) => infusion.y) - const antibodyNumbers = antibodyData.map((infusion: ChartEntry) => infusion.y) - - const largestNumberOfBleeds = Math.max(...bleedNumbers) - const largestNumberOfPreventative = Math.max(...preventativeNumbers) - const largestNumberOfProphy = Math.max(...prophyNumbers) - const largestNumberOfAntibody = Math.max(...antibodyNumbers) - - const maxY = - largestNumberOfBleeds + - largestNumberOfPreventative + - largestNumberOfProphy + - largestNumberOfAntibody - - return ( -
- - - - - - - - - -
- ) -} diff --git a/components/descriptionCards.tsx b/components/descriptionCards.tsx deleted file mode 100644 index 857c4ac..0000000 --- a/components/descriptionCards.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Text, Grid, Link, Card } from '@geist-ui/react' -import Image from 'next/image' - -export default function DescrtipionCards(): JSX.Element { - return ( - - - - - - Free forever - - - No sponsorships, pharma companies, or ads. - - - - - - - - Didn’t Hemolog die? - - - Yep, but it’s back. Just wait till you see what this reincarnation - can do! - - - - - - - - Safe and secure - - - Your data is stored in Firebase, a trused database owned by Google. - - - - - - - - Open source - - - Check out the code on{' '} - - Github - - . - - - - - ) -} diff --git a/components/emergencyCard.tsx b/components/emergencyCard.tsx deleted file mode 100644 index 0ff88da..0000000 --- a/components/emergencyCard.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useContext } from 'react' -import Link from 'next/link' -import { - Grid, - Spacer, - Loading, - useTheme, - Tooltip, - Text, - useMediaQuery, -} from '@geist-ui/react' -import styled, { ThemeContext } from 'styled-components' -import QRCode from 'react-qr-code' - -import { useAuth } from 'lib/auth' -import useDbUser from 'lib/hooks/useDbUser' - -interface Props { - forPrint?: boolean -} - -export default function EmergencyCard({ forPrint }: Props): JSX.Element { - const { user } = useAuth() - const { person } = useDbUser(user?.uid || '') - const theme = useTheme() - // biome-ignore lint/suspicious/noExplicitAny: TODO: fix when moving to tailwind - const themeContext = useContext(ThemeContext) as any - const isMobile = useMediaQuery('xs', { match: 'down' }) - - if (isMobile) { - forPrint = true - } - - const alertUrl = `hemolog.com/emergency/${person?.alertId}` - - return ( - - - - -
- Bleeding disorder -
-

Emergency

-
- - {user?.photoUrl && ( - - - - )} -
-
- - - - - {person ? ( - - ) : ( - - )} - - - - - {person ? ( - -

{person?.name}

-
- {person?.severity} Hemophilia {person?.hemophiliaType} -
- {person?.factor &&
Treat with factor {person.factor}
} - - Scan or visit for treatment history - - - -

- {alertUrl} -

-
- -
-
-
- ) : ( - - )} -
-
-
- ) -} - -const StyledEmergencyCard = styled.div<{ forPrint?: boolean }>` - position: relative; - width: ${(props) => (props.forPrint ? '308px' : '525px')}; - height: ${(props) => (props.forPrint ? '192px' : '300px')}; - border-radius: 20px; - overflow: hidden; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); - - h2, - h3, - h4, - h5, - h6 { - padding: 0; - margin: 0; - line-height: ${(props) => (props.forPrint ? '15px' : '24px')}; - font-size: ${(props) => (props.forPrint ? '75%' : '100%')}; - } - - h5 { - font-weight: 400; - } -` - -const StyledHeader = styled.div<{ forPrint?: boolean; accentColor: string }>` - background-color: ${(props) => props.accentColor}; - height: ${(props) => (props.forPrint ? '56px' : '90px')}; - width: 100%; - padding: ${(props) => (props.forPrint ? '16px' : '24px')}; -` - -const StyledPersonalInfo = styled.div<{ forPrint?: boolean }>` - padding-left: ${(props) => (props.forPrint ? '8px' : '16px')}; -` - -const StyledQRCode = styled.div<{ forPrint?: boolean; accentColor: string }>` - position: relative; - width: ${(props) => (props.forPrint ? '96px' : '148px')}; - height: ${(props) => (props.forPrint ? '96px' : '148px')}; - padding: ${(props) => (props.forPrint ? '5px' : '8px')}; - border-radius: 8px; - border: ${(props) => (props.forPrint ? '3px' : '4px')} solid - ${(props) => props.accentColor}; -` - -const StyledScanLink = styled.div<{ forPrint?: boolean }>` - padding-top: ${(props) => (props.forPrint ? '8px' : '16px')}; -` - -const StyledBloodDrop = styled.img<{ forPrint?: boolean }>` - position: absolute; - left: ${(props) => (props.forPrint ? '34px' : '56px')}; - top: ${(props) => (props.forPrint ? '-18px' : '-24px')}; - width: ${(props) => (props.forPrint ? '24px' : '32px')}; - height: ${(props) => (props.forPrint ? '24px' : '32px')}; - border: none !important; -` - -const StyledAvatar = styled.img<{ forPrint?: boolean }>` - width: ${(props) => (props.forPrint ? '60px' : '100px')}; - height: ${(props) => (props.forPrint ? '60px' : '100px')}; - border-radius: 50%; - border: ${(props) => (props.forPrint ? '4px' : '8px')} solid white; -` diff --git a/components/emergencyInfo.tsx b/components/emergencyInfo.tsx deleted file mode 100644 index 8a8d8f6..0000000 --- a/components/emergencyInfo.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { - Grid, - Avatar, - Note, - Spacer, - Text, - useMediaQuery, -} from '@geist-ui/react' -import styled from 'styled-components' - -import InfusionTable from 'components/infusionTable' -import type { Person } from 'lib/types/person' -import { useAuth } from 'lib/auth' - -interface Props { - person: Person -} - -export default function EmergencyInfo(props: Props): JSX.Element { - const { person } = props - const { user } = useAuth() - const smallerThanSmall = useMediaQuery('xs', { match: 'down' }) - - if (person) { - return ( - <> - - - -
- {person.name} - - {person.severity} Hemophilia {person.hemophiliaType}, treat with - factor {person.factor} - -
-
- - - - Most recent treatments - {smallerThanSmall && Swipe →} - - - - - Pay attention to the date on each of these logs. We’re only showing - you the 3 most recent logs. If you want to see more,{' '} - {person.name?.split(' ')[0]} will have to give you - permission. - - - - - {user && ( - <> - Emergency contacts (coming soon) - - Soon you’ll be able to add these from your settings page. - - - )} - - {/* NOTE(michael) remember when you implement this that you remember - to update the example logic on /emergency/alertId as to not - leak my actual emergency contact's info */} - - {/* - - - - Jenifer Schultz - - - 555-555-5555 - - - - - - Mike Schultz - - - 555-555-5555 - - - */} - - ) - } - - return ( - - This person’s information could not be found. - - ) -} - -const StyledRow = styled.div` - display: flex; - align-items: center; - flex-shrink: 0; - - h3, - h5 { - margin: 0; - } - - div { - display: flex; - flex-direction: column; - padding-left: 16px; - } -` diff --git a/components/emergencySnippet.tsx b/components/emergencySnippet.tsx deleted file mode 100644 index 7e6bcb3..0000000 --- a/components/emergencySnippet.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Grid, Snippet } from '@geist-ui/react' - -interface Props { - alertId: string - style?: React.CSSProperties -} - -export default function EmergencySnippet(props: Props): JSX.Element { - const { alertId = 'example', style } = props - const env = process.env.NODE_ENV - const domain = env === 'development' ? 'localhost:3000' : 'hemolog.com' - - return ( - - - - ) -} diff --git a/components/feedbackFishFooter.tsx b/components/feedbackFishFooter.tsx deleted file mode 100644 index 3a63bab..0000000 --- a/components/feedbackFishFooter.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Text, Page, Grid, Link } from '@geist-ui/react' -// import { FeedbackFish } from '@feedback-fish/react' - -export default function Footer(): JSX.Element { - // NOTE(michael): testing out https://feedback.fish. - // const PROJECT_ID = process.env.FEEDBACK_FISH_PROJECT_ID - - return ( - - - {/* - - */} - - - Built by{' '} - - Michael Schultz - - - - - ) -} diff --git a/components/feedbackModal.tsx b/components/feedbackModal.tsx deleted file mode 100644 index cda2d93..0000000 --- a/components/feedbackModal.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Modal, Textarea, Text, Spacer, useToasts } from '@geist-ui/react' -import { useFormik } from 'formik' - -import { useAuth } from 'lib/auth' -import { createFeedback, type FeedbackType } from 'lib/db/feedback' -import type { AttachedUserType } from 'lib/types/users' - -interface FeedbackValues { - message: string -} - -interface FeedbackModalProps { - visible: boolean - setVisible: (flag: boolean) => void - bindings: Record -} - -export default function FeedbackModal(props: FeedbackModalProps): JSX.Element { - const { visible, setVisible, bindings } = props - const [, setToast] = useToasts() - const { user } = useAuth() - - const handleCreateFeedback = async (feedback: FeedbackValues) => { - const feedbackUser: AttachedUserType = { - email: user?.email || '', - name: user?.name || '', - photoUrl: user?.photoUrl || '', - uid: user?.uid || '', - } - - const feedbackPayload: FeedbackType = { - ...feedback, - createdAt: new Date().toISOString(), - user: feedbackUser, - } - - createFeedback(feedbackPayload) - .then(() => { - setToast({ - text: "Feedback submitted! We'll respond soon via email.", - type: 'success', - delay: 5000, - }) - closeModal() - }) - .catch((error: unknown) => - setToast({ - text: `Something went wrong: ${error instanceof Error ? error.message : String(error)}`, - type: 'error', - delay: 10000, - }) - ) - } - - const closeModal = () => { - setVisible(false) - formik.resetForm() - } - - const formik = useFormik({ - initialValues: { - message: '', - }, - onSubmit: async (values) => { - await handleCreateFeedback(values) - }, - }) - - return ( - - Feedback - Hemolog.com - -

- If you’ve run into a bug or have an idea for how Hemolog could work - better for you, let me know. -

- -
- {/* Name - */} - - Your feedback -