diff --git a/.github/workflows/cd-build.yaml b/.github/workflows/cd-build.yaml index d9582af63..db48ca371 100644 --- a/.github/workflows/cd-build.yaml +++ b/.github/workflows/cd-build.yaml @@ -33,6 +33,8 @@ jobs: - name: Build and Push uses: docker/build-push-action@v6 with: + context: . + file: ./apps/${{ matrix.image }}/Dockerfile target: ${{ matrix.image }}-prod tags: ${{ secrets.DOCKER_USERNAME }}/bt-${{ matrix.image }}:${{ inputs.image_tag }} cache-from: | diff --git a/.github/workflows/cd-deploy-docs.yaml b/.github/workflows/cd-deploy-docs.yaml index 38a02bc73..d85e17125 100644 --- a/.github/workflows/cd-deploy-docs.yaml +++ b/.github/workflows/cd-deploy-docs.yaml @@ -7,7 +7,7 @@ concurrency: on: push: branches: [master, gql] - paths: ["docs/**", "infra/docs/**"] + paths: ["apps/docs/**", "infra/docs/**"] jobs: build-push: @@ -30,7 +30,8 @@ jobs: - name: Build and Push uses: docker/build-push-action@v6 with: - context: docs + context: . + file: ./apps/docs/Dockerfile target: docs-prod tags: ${{ secrets.DOCKER_USERNAME }}/bt-docs:prod cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/bt-docs:prod-cache diff --git a/.github/workflows/cd-deploy-storybook.yaml b/.github/workflows/cd-deploy-storybook.yaml index b8d317133..bb15ab95d 100644 --- a/.github/workflows/cd-deploy-storybook.yaml +++ b/.github/workflows/cd-deploy-storybook.yaml @@ -3,7 +3,13 @@ name: Deploy Storybook on: push: branches: [master, gql] - paths: ["packages/common/src/**", ".storybook/**", "Dockerfile", "docker-compose.yml", ".github/workflows/cd-deploy-storybook.yaml", "infra/storybook/**"] + paths: + [ + "packages/theme/**", + "apps/storybook/**", + ".github/workflows/cd-deploy-storybook.yaml", + "infra/storybook/**", + ] jobs: build-push-image: @@ -26,7 +32,9 @@ jobs: - name: Build and Push uses: docker/build-push-action@v6 with: + context: . target: storybook-prod + file: ./apps/storybook/Dockerfile tags: ${{ secrets.DOCKER_USERNAME }}/bt-storybook:prod cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/bt-storybook:prod-cache cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/bt-storybook:prod-cache,mode=max @@ -56,7 +64,6 @@ jobs: helm package storybook --version 0.1.0 --dependency-update helm push bt-storybook-0.1.0.tgz oci://registry-1.docker.io/${{ secrets.DOCKER_USERNAME }} - deploy: name: SSH and Deploy needs: [build-push-image, build-push-chart] diff --git a/.github/workflows/cd-dev.yaml b/.github/workflows/cd-dev.yaml index 319d4495c..cc6f64678 100644 --- a/.github/workflows/cd-dev.yaml +++ b/.github/workflows/cd-dev.yaml @@ -1,24 +1,17 @@ -name: Deploy to Development - -concurrency: - group: dev-${{ github.ref }} - cancel-in-progress: true +name: Deploy to Dev on: workflow_dispatch: inputs: ttl: - description: "Deployment time to live in hours" + description: "Deployment time to live in seconds" required: true type: number - default: 1 + default: 86400 jobs: - compute-sha: - name: Compute sha_short + deploy: runs-on: ubuntu-latest - outputs: - sha_short: ${{ steps.vars.outputs.sha_short }} steps: - name: Checkout Repository @@ -26,75 +19,39 @@ jobs: - name: Set vars id: vars - run: | - echo "sha_short=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_OUTPUT + run: echo "sha_short=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV - build-push: - name: Build and Push Images and Charts - needs: [compute-sha] - uses: ./.github/workflows/cd-build.yaml - with: - image_tag: ${{ needs.compute-sha.outputs.sha_short }} - chart_ver: 0.1.0-dev-${{ needs.compute-sha.outputs.sha_short }} - secrets: inherit + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} - deploy: - name: SSH and Deploy - needs: [compute-sha, build-push] - uses: ./.github/workflows/cd-deploy.yaml - with: - environment: development - name: bt-dev-app-${{ needs.compute-sha.outputs.sha_short }} - version: 0.1.0-dev-${{ needs.compute-sha.outputs.sha_short }} - values: | - env: dev - ttl: ${{ inputs.ttl }} - frontend: - image: - tag: '${{ needs.compute-sha.outputs.sha_short }}' - backend: - image: - tag: '${{ needs.compute-sha.outputs.sha_short }}' - datapuller: - suspend: true - image: - tag: '${{ needs.compute-sha.outputs.sha_short }}' - host: ${{ needs.compute-sha.outputs.sha_short }}.dev.stanfurdtime.com - mongoUri: mongodb://bt-dev-mongo-mongodb-0.bt-dev-mongo-mongodb-headless.bt.svc.cluster.local:27017/bt - redisUri: redis://bt-dev-redis-master.bt.svc.cluster.local:6379 - host: ${{ needs.compute-sha.outputs.sha_short }}.dev.stanfurdtime.com - secrets: inherit + - name: Build Images with Tags + run: | + docker build --target backend-dev --tag "${{ secrets.DOCKER_USERNAME }}/bt-backend:${{ env.sha_short }}" . + docker build --target frontend-dev --tag "${{ secrets.DOCKER_USERNAME }}/bt-frontend:${{ env.sha_short }}" . - limit-deploy: - name: SSH and Limit Deployments - needs: [deploy] - runs-on: ubuntu-latest - steps: - - name: SSH and Check Deployments - uses: appleboy/ssh-action@v1.2.0 + - name: Push Images to Docker Hub + run: | + docker push "${{ secrets.DOCKER_USERNAME }}/bt-backend:${{ env.sha_short }}" + docker push "${{ secrets.DOCKER_USERNAME }}/bt-frontend:${{ env.sha_short }}" + + - name: SSH and Helm Install + uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USERNAME }} + username: root key: ${{ secrets.SSH_KEY }} script: | - set -e # Exit immediately if a command fails - - # Get bt-dev-app- deployments sorted by creation timestamp - deployments=$(helm list \ - --namespace=bt \ - --date \ - --short | grep '^bt-dev-app') || true - deployment_count=$(echo "$deployments" | wc -l) - - # Check if deployment count > 8 - if [ "$deployment_count" -gt 8 ]; then - echo "Too many deployments. Deleting the oldest deployment." - - # Get oldest deployment from first line of deployments - oldest_deployment=$(echo "$deployments" | head -n 1) - - # Uninstall deployment - helm uninstall "${oldest_deployment}" - else - echo "Deployment count is <= 8." - fi + cd ./infra + helm uninstall bt-dev-app-${{ env.sha_short }} || true + helm install bt-dev-app-${{ env.sha_short }} ./app --namespace=bt \ + --set env=dev \ + --set ttl=${{ inputs.ttl }} \ + --set frontend.image.tag=${{ env.sha_short }} \ + --set backend.image.tag=${{ env.sha_short }} \ + --set host=${{ env.sha_short }}.stanfurdtime.com \ + --set mongoUri=mongodb://bt-dev-mongo-mongodb.bt.svc.cluster.local:27017/bt \ + --set redisUri=redis://bt-dev-redis-master.bt.svc.cluster.local:6379 \ + --set nodeEnv=development diff --git a/.github/workflows/cd-prod.yaml b/.github/workflows/cd-prod.yaml index a87d177cc..a8bbdec43 100644 --- a/.github/workflows/cd-prod.yaml +++ b/.github/workflows/cd-prod.yaml @@ -1,38 +1,129 @@ name: Deploy to Production -concurrency: production +concurrency: prod on: workflow_dispatch: +env: + artifact-retention-days: 7 + jobs: branch-check: - name: Environment Check + name: Check Branch runs-on: ubuntu-latest - environment: production + environment: prod steps: - name: Pass run: echo "Passed check" - build-push: - name: Build and Push Images and Charts - needs: [branch-check] - uses: ./.github/workflows/cd-build.yaml - with: - image_tag: prod - chart_ver: "1.0.0" - secrets: inherit + build-backend: + name: Build Backend Image + needs: branch-check + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Build Image with Tag + run: | + docker build --target backend-prod --tag "${{ secrets.DOCKER_USERNAME }}/bt-backend:prod" . + docker save "${{ secrets.DOCKER_USERNAME }}/bt-backend:prod" --output "bt-backend-prod.tar" + + - name: Upload Image as Artifact + uses: actions/upload-artifact@v4 + with: + name: "bt-backend-prod.tar" + path: "bt-backend-prod.tar" + retention-days: ${{ env.artifact-retention-days }} + overwrite: true + + build-frontend: + name: Build Frontend Image + needs: branch-check + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Build Image with Tag + run: | + docker build --target frontend-prod --tag "${{ secrets.DOCKER_USERNAME }}/bt-frontend:prod" . + docker save "${{ secrets.DOCKER_USERNAME }}/bt-frontend:prod" --output "bt-frontend-prod.tar" + + - name: Upload Image as Artifact + uses: actions/upload-artifact@v4 + with: + name: "bt-frontend-prod.tar" + path: "bt-frontend-prod.tar" + retention-days: ${{ env.artifact-retention-days }} + overwrite: true + + push-backend: + name: Push Backend Image + needs: build-backend + runs-on: ubuntu-latest + + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Download Artifact as Image + uses: actions/download-artifact@v4 + with: + name: "bt-backend-prod.tar" + + - name: Push Image to Docker Hub + run: | + docker import "bt-backend-prod.tar" "${{ secrets.DOCKER_USERNAME }}/bt-backend:prod" + docker push "${{ secrets.DOCKER_USERNAME }}/bt-backend:prod" + + push-frontend: + name: Push Frontend Image + needs: build-frontend + runs-on: ubuntu-latest + + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Download Artifact as Image + uses: actions/download-artifact@v4 + with: + name: "bt-frontend-prod.tar" + + - name: Push Image to Docker Hub + run: | + docker import "bt-frontend-prod.tar" "${{ secrets.DOCKER_USERNAME }}/bt-frontend:prod" + docker push "${{ secrets.DOCKER_USERNAME }}/bt-frontend:prod" deploy: - name: SSH and Deploy - needs: [build-push] - uses: ./.github/workflows/cd-deploy.yaml - with: - environment: production - name: bt-prod-app - version: "1.0.0" - values: | - host: beta.berkeleytime.com - host: beta.berkeleytime.com - secrets: inherit + name: Deploy with SSH + needs: [push-backend, push-frontend] + runs-on: ubuntu-latest + + steps: + - name: SSH and Helm Install + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: root + key: ${{ secrets.SSH_KEY }} + script: | + cd ./infra + if helm status bt-prod-app ; then + kubectl rollout restart bt-prod-app-backend + kubectl rollout restart bt-prod-app-frontend + else + helm install bt-prod-app ./app --namespace=bt \ + --set host=stanfurdtime.com + fi diff --git a/.github/workflows/helm-diff.yaml b/.github/workflows/helm-diff.yaml index e0918f35d..4b98ec2ab 100644 --- a/.github/workflows/helm-diff.yaml +++ b/.github/workflows/helm-diff.yaml @@ -1,26 +1,18 @@ name: Generate Helm Diffs for PR on: - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to generate diff for' - required: true - type: string issue_comment: types: [created] jobs: helm-diff: - if: | - github.event_name == 'workflow_dispatch' || - (github.event.issue.pull_request && contains(github.event.comment.body, '/helm-diff')) + if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, '/helm-diff') }} runs-on: ubuntu-latest steps: - name: Checkout PR Code uses: actions/checkout@v4 with: - ref: refs/pull/${{ inputs.pr_number }}/head + ref: refs/pull/${{ github.event.issue.number }}/head - name: Set up Helm uses: azure/setup-helm@v1 @@ -98,7 +90,7 @@ jobs: fi } - # First update dependencies if needed + # Update chart dependencies if needed helm dependency update ./infra/app helm dependency update ./infra/base @@ -111,7 +103,7 @@ jobs: --namespace=bt \ --set host=stanfurdtime.com \ --version 1.0.0 2>&1 | tee -a /tmp/raw_diffs.log | process_diff - + # Process base chart diff output and log raw diff echo "" >> diff_output.md echo "#### Base Chart Changes" >> diff_output.md @@ -155,10 +147,8 @@ jobs: await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: Number(process.env.PR_NUMBER), + issue_number: ${{ github.event.issue.number }}, body: process.env.DIFF }); env: - PR_NUMBER: ${{ inputs.pr_number }} DIFF: ${{ steps.generate-diff.outputs.diff }} - diff --git a/.github/workflows/runbook-reset-dev-mongo.yaml b/.github/workflows/runbook-reset-dev-mongo.yaml index 23b47025a..299c987af 100644 --- a/.github/workflows/runbook-reset-dev-mongo.yaml +++ b/.github/workflows/runbook-reset-dev-mongo.yaml @@ -17,11 +17,4 @@ jobs: script: | set -e # Exit immediately if a command fails - # Create Mongo job from mongo-reset - kubectl create job --from=cronjob/bt-base-reset-dev-mongo bt-base-reset-dev-mongo-ga-manual - echo "MongoDB reset scheduled." - - # Wait for job_pod log output - job_pod=$(kubectl get pods -o custom-columns=NAME:.metadata.name --no-headers -n bt | grep 'bt-base-reset-dev-mongo-ga-manual') - kubectl wait --for=condition=ready pod/$job_pod -n bt --timeout=30s - kubectl logs -f $job_pod -n bt + echo "not implemented yet" diff --git a/.github/workflows/runbook-restore-prod-mongo.yaml b/.github/workflows/runbook-restore-prod-mongo.yaml index b591858bf..8373d830c 100644 --- a/.github/workflows/runbook-restore-prod-mongo.yaml +++ b/.github/workflows/runbook-restore-prod-mongo.yaml @@ -1,4 +1,4 @@ -name: Restore Prod Mongo +name: Reset Dev Mongo on: workflow_dispatch: @@ -10,7 +10,7 @@ on: jobs: restore-mongo: - name: SSH and Restore Prod MongoDB State + name: SSH and Reset Prod MongoDB State runs-on: ubuntu-latest steps: - name: SSH and Reset MongoDB @@ -22,15 +22,4 @@ jobs: script: | set -e # Exit immediately if a command fails - # Create Mongo job from mongo-restore - restore_date="${{ github.event.inputs.restore_date }}" - kubectl create job --from=cronjob/bt-base-restore-prod-mongo bt-base-restore-prod-mongo-ga-manual \ - --dry-run=client -o json \ - | jq ".spec.template.spec.containers[0].env += [{\"name\": \"RESTORE_DATE\", \"value\": \"$restore_date\"}]" \ - | kubectl apply -f - - echo "MongoDB restore from $restore_date scheduled." - - # Wait for job_pod log output - job_pod=$(kubectl get pods -o custom-columns=NAME:.metadata.name --no-headers -n bt | grep 'bt-base-restore-prod-mongo-ga-manual') - kubectl wait --for=condition=ready pod/$job_pod -n bt --timeout=30s - kubectl logs -f $job_pod -n bt + echo "not implemented yet" diff --git a/.gitignore b/.gitignore index a4fdbad55..84022f1e2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,7 @@ bt-app-*.tgz generated-types/ generated-types stage_backup.gz -apps/frontend/tsconfig.node.tsbuildinfo -apps/frontend/tsconfig.tsbuildinfo -apps/frontend/vite.config.d.ts -apps/frontend/vite.config.js *storybook.log -storybook-static -vitest.config.js \ No newline at end of file +vitest.config.js +apps/backend/src/scripts +prod-backup.gz \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..63629cc06 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,94 @@ +services: + - docker:19.03.13-dind + +variables: + DOCKER_HOST: tcp://docker:2376 + DOCKER_TLS_CERTDIR: /certs + DOCKER_TLS_VERIFY: 1 + DOCKER_CERT_PATH: $DOCKER_TLS_CERTDIR/client + FILEPATH_DEPLOY_BACKEND: infra/k8s/default/bt-backend.yaml + FILEPATH_DEPLOY_DATA: infra/k8s/default/bt-backend-data-updater.yaml + FILEPATH_DEPLOY_FRONTEND: infra/k8s/default/bt-frontend.yaml + FILEPATH_DEPLOY_INGRESS: infra/k8s/default/bt-ingress-tricycle.yaml + FILEPATH_LOCAL_DOCKER_COMPOSE_BACKEND: backend + FILEPATH_LOCAL_DOCKER_COMPOSE_FRONTEND: frontend + +stages: + - build + - deploy-dev + - deploy-staging + - deploy-prod + +.before_build: + before_script: + - until docker info; do sleep 1; done; + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + - | + tag=":$CI_COMMIT_BRANCH" + echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag" + +build-backend: + stage: build + image: docker:19.03.13-dind + extends: .before_build + script: + - docker build -t "${CI_REGISTRY_IMAGE}/bt-backend${tag}" $FILEPATH_LOCAL_DOCKER_COMPOSE_BACKEND + - docker push "${CI_REGISTRY_IMAGE}/bt-backend${tag}" + +build-frontend: + stage: build + image: docker:19.03.13-dind + extends: .before_build + script: + - docker build -t "${CI_REGISTRY_IMAGE}/bt-frontend${tag}" $FILEPATH_LOCAL_DOCKER_COMPOSE_FRONTEND + - docker push "${CI_REGISTRY_IMAGE}/bt-frontend${tag}" + +deploy-dev: + stage: deploy-dev + except: + refs: + - master + environment: + name: staging + image: $CI_SERVER_HOST:8880/berkeleytime/bt-gitlab-runner + script: + - npm --prefix infra/tricycle install && node infra/tricycle + - kubectl rollout restart deploy/bt-backend-dev-$CI_COMMIT_BRANCH + - kubectl rollout restart deploy/bt-frontend-dev-$CI_COMMIT_BRANCH + - kubectl rollout status --timeout=1200s deploy/bt-backend-dev-$CI_COMMIT_BRANCH + - kubectl rollout status --timeout=1200s deploy/bt-frontend-dev-$CI_COMMIT_BRANCH + +deploy-staging: + stage: deploy-staging + only: + refs: + - master + environment: + name: staging + image: $CI_SERVER_HOST:8880/berkeleytime/bt-gitlab-runner + script: + - envsubst < $FILEPATH_DEPLOY_BACKEND | kubectl apply -f - --kubeconfig $SECRET_KUBERNETES_CREDENTIALS + - envsubst < $FILEPATH_DEPLOY_DATA | kubectl apply -f - --kubeconfig $SECRET_KUBERNETES_CREDENTIALS + - envsubst < $FILEPATH_DEPLOY_FRONTEND| kubectl apply -f - --kubeconfig $SECRET_KUBERNETES_CREDENTIALS + - kubectl rollout restart deploy/bt-backend-staging + - kubectl rollout restart deploy/bt-frontend-staging + - kubectl rollout status --timeout=1200s deploy/bt-backend-staging + - kubectl rollout status --timeout=1200s deploy/bt-frontend-staging + +deploy-prod: + stage: deploy-prod + only: + refs: + - master + environment: + name: prod + when: manual + image: $CI_SERVER_HOST:8880/berkeleytime/bt-gitlab-runner + script: + - envsubst < $FILEPATH_DEPLOY_BACKEND | kubectl apply -f - --kubeconfig $SECRET_KUBERNETES_CREDENTIALS + - envsubst < $FILEPATH_DEPLOY_DATA | kubectl apply -f - --kubeconfig $SECRET_KUBERNETES_CREDENTIALS + - envsubst < $FILEPATH_DEPLOY_FRONTEND| kubectl apply -f - --kubeconfig $SECRET_KUBERNETES_CREDENTIALS + - kubectl rollout restart deploy/bt-backend-prod + - kubectl rollout restart deploy/bt-frontend-prod + - kubectl rollout status --timeout=1200s deploy/bt-backend-prod + - kubectl rollout status --timeout=1200s deploy/bt-frontend-prod diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index 9dc869886..000000000 --- a/.storybook/main.js +++ /dev/null @@ -1,31 +0,0 @@ - - -import { join, dirname } from "path" - -/** -* This function is used to resolve the absolute path of a package. -* It is needed in projects that use Yarn PnP or are set up within a monorepo. -*/ -function getAbsolutePath(value) { - return dirname(require.resolve(join(value, 'package.json'))) -} - -/** @type { import('@storybook/react-vite').StorybookConfig } */ -const config = { - "stories": [ - "../stories/**/*.mdx", - "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)", - "../packages/theme/src/stories/*.stories.@(js|jsx|mjs|ts|tsx)" - ], - "addons": [ - getAbsolutePath('@chromatic-com/storybook'), - getAbsolutePath('@storybook/addon-docs'), - getAbsolutePath("@storybook/addon-a11y"), - getAbsolutePath("@storybook/addon-vitest") - ], - "framework": { - "name": getAbsolutePath('@storybook/react-vite'), - "options": {} - } -}; -export default config; \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 80254dce4..000000000 --- a/Dockerfile +++ /dev/null @@ -1,88 +0,0 @@ -FROM node:22-alpine AS base -RUN ["npm", "install", "-g", "turbo@latest"] - -# datapuller -FROM base AS datapuller-builder -WORKDIR /datapuller -COPY . . - -RUN ["turbo", "prune", "datapuller", "--docker"] - -FROM base AS datapuller-dev -WORKDIR /datapuller - -COPY --from=datapuller-builder /datapuller/out/json/ . -COPY --from=datapuller-builder /datapuller/out/package-lock.json ./package-lock.json -RUN ["npm", "install"] - -COPY --from=datapuller-builder /datapuller/out/full/ . - -RUN ["turbo", "run", "build", "--filter=datapuller"] -ENTRYPOINT ["turbo", "run", "main", "--filter=datapuller", "--"] -CMD ["--puller=main"] - -FROM datapuller-dev AS datapuller-prod -WORKDIR /datapuller -ENTRYPOINT ["turbo", "run", "main", "--filter=datapuller", "--env-mode=loose", "--"] - -# backend -FROM base AS backend-builder -WORKDIR /backend -COPY . . -RUN ["turbo", "prune", "backend", "--docker"] - -FROM base AS backend-dev -WORKDIR /backend - -COPY --from=backend-builder /backend/out/json/ . -COPY --from=backend-builder /backend/out/package-lock.json ./package-lock.json -RUN ["npm", "install"] - -COPY --from=backend-builder /backend/out/full/ . -ENTRYPOINT ["turbo", "run", "dev", "--filter=backend"] - -FROM backend-dev AS backend-prod -ENTRYPOINT ["turbo", "run", "start", "--filter=backend", "--env-mode=loose"] - -# frontend -FROM base AS frontend-builder -WORKDIR /frontend -COPY . . -RUN ["turbo", "prune", "frontend", "--docker"] - -FROM base AS frontend-dev -WORKDIR /frontend - -COPY --from=frontend-builder /frontend/out/json/ . -COPY --from=frontend-builder /frontend/out/package-lock.json ./package-lock.json -RUN ["npm", "install"] - -COPY --from=frontend-builder /frontend/out/full/ . -ENTRYPOINT ["turbo", "run", "dev", "--filter=frontend"] - -FROM frontend-dev AS frontend-prod -RUN ["turbo", "run", "build", "--filter=frontend", "--env-mode=loose"] - -ENTRYPOINT ["turbo", "run", "start", "--filter=frontend"] - -# storybook -FROM base AS storybook-builder -WORKDIR /storybook - -COPY --from=frontend-builder /frontend/out/json/ . -COPY --from=frontend-builder /frontend/out/package-lock.json ./package-lock.json -RUN ["npm", "install"] - -COPY --from=frontend-builder /frontend/out/full/ . - -COPY .storybook .storybook - -RUN ["npm", "run", "build-storybook"] - -FROM storybook-builder AS storybook-dev -ENTRYPOINT ["npm", "run", "storybook", "--", "--no-open"] - -FROM nginx:alpine AS storybook-prod -COPY .storybook/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=storybook-builder /storybook/storybook-static /var/www/html -EXPOSE 80 \ No newline at end of file diff --git a/README.md b/README.md index 9bd7b11ec..1f27ae9b8 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,4 @@ Berkeleytime was created by [Yuxin Zhu](http://yuxinzhu.com/) and [Noah Gilmore] # Getting started -Follow the instructions on the [Getting Started page of the docs](https://docs.stanfurdtime.com/getting-started/local-development.html) to setup! +Much of the current development is on [the gql branch](https://github.com/asuc-octo/berkeleytime/tree/gql). diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 000000000..41be9dbc9 --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:alpine AS base +RUN ["npm", "install", "-g", "turbo@latest"] + +FROM base AS backend-builder +WORKDIR /backend +COPY . . +RUN ["turbo", "prune", "backend", "--docker"] + +FROM base AS backend-dev +WORKDIR /backend + +COPY --from=backend-builder /backend/out/json/ . +COPY --from=backend-builder /backend/out/package-lock.json ./package-lock.json +RUN ["npm", "install"] + +COPY --from=backend-builder /backend/out/full/ . +ENTRYPOINT ["turbo", "run", "dev", "--filter=backend"] + +FROM backend-dev AS backend-prod +ENTRYPOINT ["turbo", "run", "start", "--filter=backend", "--env-mode=loose"] \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index d2cd7549c..c3c6f47bc 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -8,43 +8,43 @@ "generate": "graphql-codegen --config codegen.ts" }, "devDependencies": { - "@babel/core": "^7.28.0", - "@babel/preset-env": "^7.28.0", + "@babel/core": "^7.28.4", + "@babel/preset-env": "^7.28.3", "@babel/preset-typescript": "^7.27.1", - "@graphql-codegen/cli": "5.0.7", - "@graphql-codegen/graphql-modules-preset": "^4.0.17", - "@graphql-codegen/introspection": "4.0.3", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/typescript-resolvers": "^4.5.1", + "@graphql-codegen/cli": "6.0.0", + "@graphql-codegen/graphql-modules-preset": "^5.0.3", + "@graphql-codegen/introspection": "5.0.0", + "@graphql-codegen/typescript": "^5.0.2", + "@graphql-codegen/typescript-resolvers": "^5.1.0", "@repo/typescript-config": "*", "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/express-session": "^1.18.2", "@types/lodash": "^4.17.20", - "@types/node": "^24.1.0", + "@types/node": "^24.7.0", "@types/papaparse": "^5.3.16", "@types/passport-google-oauth20": "^2.0.16", - "tsx": "^4.20.3", - "typescript": "^5.8.3" + "tsx": "^4.20.6", + "typescript": "^5.9.3" }, "dependencies": { "@apollo/server": "^5.0.0", "@apollo/server-plugin-response-cache": "^5.0.0", "@apollo/utils.keyvadapter": "^4.0.1", "@as-integrations/express5": "^1.1.2", - "@aws-sdk/client-athena": "^3.857.0", - "@aws-sdk/client-s3": "^3.857.0", - "@escape.tech/graphql-armor": "^3.1.6", + "@aws-sdk/client-athena": "^3.901.0", + "@aws-sdk/client-s3": "^3.901.0", + "@escape.tech/graphql-armor": "^3.1.7", "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", - "@keyv/redis": "^5.0.0", + "@keyv/redis": "^5.1.2", "@repo/common": "*", "@repo/shared": "*", "@repo/sis-api": "*", "compression": "^1.8.1", "connect-redis": "^9.0.0", "cors": "^2.8.5", - "dotenv": "^17.2.1", + "dotenv": "^17.2.3", "express": "^5.1.0", "express-session": "^1.18.2", "fuse.js": "^7.1.0", @@ -52,13 +52,13 @@ "graphql-modules": "^3.0.0", "graphql-type-json": "^0.3.2", "helmet": "^8.1.0", - "keyv": "^5.4.0", + "keyv": "^5.5.3", "lodash": "^4.17.21", - "mongodb": "^6.18.0", - "mongoose": "^8.17.0", + "mongodb": "^6.20.0", + "mongoose": "^8.19.1", "papaparse": "^5.5.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", - "redis": "^5.6.1" + "redis": "^5.8.3" } } diff --git a/apps/backend/src/modules/index.ts b/apps/backend/src/modules/index.ts index 7aca578af..0aac7cd0e 100644 --- a/apps/backend/src/modules/index.ts +++ b/apps/backend/src/modules/index.ts @@ -5,6 +5,7 @@ import Class from "./class"; import Common from "./common"; import Course from "./course"; import Enrollment from "./enrollment"; +import Plan from "./plan"; import GradeDistribution from "./grade-distribution"; import Rating from "./rating"; import Schedule from "./schedule"; @@ -21,6 +22,7 @@ const modules = [ Course, Class, Enrollment, + Plan, Rating, ]; diff --git a/apps/backend/src/modules/plan/controller.ts b/apps/backend/src/modules/plan/controller.ts new file mode 100644 index 000000000..f68af725b --- /dev/null +++ b/apps/backend/src/modules/plan/controller.ts @@ -0,0 +1,290 @@ +import { omitBy } from "lodash"; + +import { + LabelModel, + MajorReqModel, + PlanModel, + PlanTermModel, + SelectedCourseModel, +} from "@repo/common"; + +import { + Colleges, + EditPlanTermInput, + Plan, + PlanInput, + PlanTerm, + PlanTermInput, + SelectedCourseInput, +} from "../../generated-types/graphql"; +import { formatPlan, formatPlanTerm } from "./formatter"; + +// get plan for a user +export async function getPlanByUser(context: any): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + + const gt = await PlanModel.findOne({ userEmail: context.user.email }); + if (!gt) { + throw new Error("No Plan found for this user"); + } + const tmp = formatPlan(gt); + return tmp ? [tmp] : []; +} + +// delete a planTerm specified by ObjectID +export async function removePlanTerm( + planTermID: string, + context: any +): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + + // check if planTerm belongs to plan + const gt = await PlanModel.findOne({ userEmail: context.user.email }); + if (!gt) { + throw new Error("No Plan found for this user"); + } + const planTermIndex = gt.planTerms.findIndex( + (sem) => (sem._id as string) == planTermID + ); + if (planTermIndex === -1) { + throw new Error("PlanTerm does not exist in user's plan"); + } + gt.planTerms.splice(planTermIndex, 1); + console.log(planTermIndex, gt.planTerms); + await gt.save(); + + return planTermID; +} + +// create a new planTerm +export async function createPlanTerm( + mainPlanTerm: PlanTermInput, + context: any +): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + const nonNullPlanTerm = omitBy(mainPlanTerm, (value) => value == null); + nonNullPlanTerm.userEmail = context.user.email; + const newPlanTerm = new PlanTermModel({ + ...nonNullPlanTerm, + }); + + // add to plan in chronological order + const gt = await PlanModel.findOne({ userEmail: context.user.email }); + if (!gt) { + throw new Error("No Plan found for this user"); + } + gt.planTerms.push(newPlanTerm); + await gt.save(); + + return formatPlanTerm(newPlanTerm); +} + +// update an existing planTerm +export async function editPlanTerm( + planTermID: string, + mainPlanTerm: EditPlanTermInput, + context: any +): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + const gt = await PlanModel.findOne({ userEmail: context.user.email }); + if (!gt) { + throw new Error("No Plan found for this user"); + } + + const planTermIndex = gt.planTerms.findIndex( + (sem) => (sem._id as string) == planTermID + ); + if (planTermIndex === -1) { + throw new Error("PlanTerm does not exist in user's plan"); + } + + const termToUpdate = gt.planTerms[planTermIndex]; + + if (mainPlanTerm.name != null) { + termToUpdate.name = mainPlanTerm.name; + } + if (mainPlanTerm.year != null) { + termToUpdate.year = mainPlanTerm.year; + } + if (mainPlanTerm.term != null) { + termToUpdate.term = mainPlanTerm.term; + } + if (mainPlanTerm.hidden != null) { + termToUpdate.hidden = mainPlanTerm.hidden; + } + if (mainPlanTerm.status != null) { + termToUpdate.status = mainPlanTerm.status; + } + if (mainPlanTerm.pinned != null) { + termToUpdate.pinned = mainPlanTerm.pinned; + } + if (mainPlanTerm.courses != null) { + termToUpdate.courses = mainPlanTerm.courses.map( + (courseInput) => new SelectedCourseModel(courseInput) + ); + } + + await gt.save(); + return formatPlanTerm(termToUpdate); +} + +// update class selection in an existing planTerm +export async function setClasses( + planTermID: string, + courses: SelectedCourseInput[], + context: any +): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + const gt = await PlanModel.findOne({ userEmail: context.user.email }); + if (!gt) { + throw new Error("No Plan found for this user"); + } + const planTermIndex = gt.planTerms.findIndex( + (sem) => (sem._id as string) == planTermID + ); + if (planTermIndex === -1) { + throw new Error("PlanTerm does not exist in user's plan"); + } + gt.planTerms[planTermIndex].courses = courses.map( + (courseInput) => new SelectedCourseModel(courseInput) + ); + await gt.save(); + return formatPlanTerm(gt.planTerms[planTermIndex]); +} + +// create a new plan +export async function createPlan( + colleges: Colleges[], + majors: string[], + minors: string[], + startYear: number, + endYear: number, + context: any +): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + // if existing plan, overwrite + const gt = await PlanModel.findOne({ userEmail: context.user.email }); + if (gt) { + throw new Error("User already has existing plan"); + } + const miscellaneous = new PlanTermModel({ + name: "Miscellaneous", + courses: [], + userEmail: context.user.email, + year: -1, + term: "Misc", + hidden: false, + status: "None", + pinned: false, + }); + + // Create all the plan terms + const planTerms = [miscellaneous]; + planTerms.push( + new PlanTermModel({ + name: "Fall " + startYear, + courses: [], + userEmail: context.user.email, + year: startYear, + term: "Fall", + hidden: false, + status: "None", + pinned: false, + }) + ); + for (let i = startYear + 1; i < endYear; i++) { + planTerms.push( + new PlanTermModel({ + name: "Spring " + i, + courses: [], + userEmail: context.user.email, + year: i, + term: "Spring", + hidden: false, + status: "None", + pinned: false, + }) + ); + planTerms.push( + new PlanTermModel({ + name: "Fall " + i, + courses: [], + userEmail: context.user.email, + year: i, + term: "Fall", + hidden: false, + status: "None", + pinned: false, + }) + ); + } + planTerms.push( + new PlanTermModel({ + name: "Spring " + endYear, + courses: [], + userEmail: context.user.email, + year: endYear, + term: "Spring", + hidden: false, + status: "None", + pinned: false, + }) + ); + + const newPlan = await PlanModel.create({ + userEmail: context.user.email, + planTerms: planTerms, + majors: majors, + minors: minors, + majorReqs: [], + colleges: colleges, + labels: [], + uniReqsSatisfied: [], + collegeReqsSatisfied: [], + }); + return formatPlan(newPlan); +} + +export async function editPlan(plan: PlanInput, context: any): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + const gt = await PlanModel.findOne({ userEmail: context.user.email }); + if (!gt) { + throw new Error("No Plan found for this user"); + } + + if (plan.colleges != null) { + gt.colleges = plan.colleges; + } + if (plan.majors != null) { + gt.majors = plan.majors; + } + if (plan.minors != null) { + gt.minors = plan.minors; + } + if (plan.majorReqs != null) { + gt.majorReqs = plan.majorReqs.map( + (majorReqInput) => new MajorReqModel(majorReqInput) + ); + } + if (plan.labels != null) { + gt.labels = plan.labels.map((labelInput) => new LabelModel(labelInput)); + } + if (plan.uniReqsSatisfied != null) { + gt.uniReqsSatisfied = plan.uniReqsSatisfied; + } + if (plan.collegeReqsSatisfied != null) { + gt.collegeReqsSatisfied = plan.collegeReqsSatisfied; + } + + await gt.save(); + return formatPlan(gt); +} + +export async function deletePlan(context: any): Promise { + if (!context.user.email) throw new Error("Unauthorized"); + console.log(context.user); + await PlanModel.deleteOne({ userEmail: context.user.email }).catch((err) => { + return err; + }); + return context.user.email; +} diff --git a/apps/backend/src/modules/plan/formatter.ts b/apps/backend/src/modules/plan/formatter.ts new file mode 100644 index 000000000..ed28d37b4 --- /dev/null +++ b/apps/backend/src/modules/plan/formatter.ts @@ -0,0 +1,80 @@ +import { + LabelType, + MajorReqType, + PlanTermType, + PlanType, + SelectedCourseType, +} from "@repo/common"; + +import { + CollegeReqs, + Colleges, + Status, + Terms, + UniReqs, +} from "../../generated-types/graphql"; +import { PlanModule } from "./generated-types/module-types"; + +export function formatPlan(plan: PlanType): PlanModule.Plan { + return { + _id: plan._id as string, + userEmail: plan.userEmail, + planTerms: plan.planTerms.map(formatPlanTerm), + majors: plan.majors, + minors: plan.minors, + colleges: plan.colleges.map((college) => college as Colleges), + majorReqs: plan.majorReqs.map(formatMajorReq), + created: plan.createdAt.toISOString(), + revised: plan.updatedAt.toISOString(), + labels: plan.labels.map(formatLabel), + uniReqsSatisfied: plan.uniReqsSatisfied as UniReqs[], + collegeReqsSatisfied: plan.collegeReqsSatisfied as CollegeReqs[], + }; +} + +export function formatPlanTerm(planTerm: PlanTermType): PlanModule.PlanTerm { + return { + _id: planTerm._id as string, + name: planTerm.name, + userEmail: planTerm.userEmail, + courses: planTerm.courses.map(formatCourse), + year: planTerm.year, + term: planTerm.term as Terms, + hidden: planTerm.hidden, + status: planTerm.status as Status, + pinned: planTerm.pinned, + }; +} + +export function formatMajorReq(majorReq: MajorReqType): PlanModule.MajorReq { + return { + name: majorReq.name, + major: majorReq.major, + numCoursesRequired: majorReq.numCoursesRequired, + satisfyingCourseIds: majorReq.satisfyingCourseIds + ? majorReq.satisfyingCourseIds + : [], + isMinor: majorReq.isMinor ? majorReq.isMinor : false, + }; +} + +function formatCourse(course: SelectedCourseType): PlanModule.SelectedCourse { + return { + courseID: course.courseID, + courseName: course.courseName, + courseTitle: course.courseTitle, + courseUnits: course.courseUnits, + collegeReqs: course.collegeReqs as CollegeReqs[], + uniReqs: course.uniReqs as UniReqs[], + labels: course.labels, + pnp: course.pnp, + transfer: course.transfer, + }; +} + +function formatLabel(label: LabelType): PlanModule.Label { + return { + name: label.name, + color: label.color, + }; +} diff --git a/apps/backend/src/modules/plan/index.ts b/apps/backend/src/modules/plan/index.ts new file mode 100644 index 000000000..fbc7901d7 --- /dev/null +++ b/apps/backend/src/modules/plan/index.ts @@ -0,0 +1,7 @@ +import resolver from "./resolver"; +import typeDef from "./typedefs/plan"; + +export default { + resolver, + typeDef, +}; diff --git a/apps/backend/src/modules/plan/resolver.ts b/apps/backend/src/modules/plan/resolver.ts new file mode 100644 index 000000000..32bbb4f1c --- /dev/null +++ b/apps/backend/src/modules/plan/resolver.ts @@ -0,0 +1,76 @@ +import { + Colleges, + EditPlanTermInput, + PlanInput, + PlanTermInput, + SelectedCourseInput, +} from "../../generated-types/graphql"; +import { + createPlan, + createPlanTerm, + deletePlan, + editPlan, + editPlanTerm, + getPlanByUser, + removePlanTerm, + setClasses, +} from "./controller"; +import { PlanModule } from "./generated-types/module-types"; + +const resolvers: PlanModule.Resolvers = { + Query: { + planByUser(_parent, _args, context) { + return getPlanByUser(context); + }, + }, + Mutation: { + removePlanTermByID(_parent, args: { id: string }, context) { + return removePlanTerm(args.id, context); + }, + createNewPlanTerm(_parent, args: { planTerm: PlanTermInput }, context) { + return createPlanTerm(args.planTerm, context); + }, + editPlanTerm( + _parent, + args: { id: string; planTerm: EditPlanTermInput }, + context + ) { + return editPlanTerm(args.id, args.planTerm, context); + }, + setSelectedCourses( + _parent, + args: { id: string; courses: SelectedCourseInput[] }, + context + ) { + return setClasses(args.id, args.courses, context); + }, + createNewPlan( + _parent, + args: { + colleges: Colleges[]; + majors: string[]; + minors: string[]; + startYear: number; + endYear: number; + }, + context + ) { + return createPlan( + args.colleges, + args.majors, + args.minors, + args.startYear, + args.endYear, + context + ); + }, + editPlan(_parent, args: { plan: PlanInput }, context) { + return editPlan(args.plan, context); + }, + deletePlan(_parent, _args, context) { + return deletePlan(context); + }, + }, +}; + +export default resolvers; diff --git a/apps/backend/src/modules/plan/typedefs/plan.ts b/apps/backend/src/modules/plan/typedefs/plan.ts new file mode 100644 index 000000000..589b8b76e --- /dev/null +++ b/apps/backend/src/modules/plan/typedefs/plan.ts @@ -0,0 +1,259 @@ +// TODO: Write major prereq +import { gql } from "graphql-tag"; + +const typeDef = gql` + enum Terms { + Fall + Spring + Summer + Misc + } + + enum Colleges { + "College of Letters and Sciences" + LnS + + "College of Engineering" + CoE + + "Haas School of Business" + HAAS + + "Other" + OTHER + } + + enum Status { + Complete + InProgress + Incomplete + None + } + + enum UniReqs { + AC + AH + AI + CW + QR + RCA + RCB + } + enum CollegeReqs { + "Arts and Literature" + LnS_AL + + "Biological Sciences" + LnS_BS + + "Historical Studies" + LnS_HS + + "International Studies" + LnS_IS + + "Philosophy and Values" + LnS_PV + + "Physical Science" + LnS_PS + + "Social and Behavioral Sciences" + LnS_SBS + + "Humanities and Social Sciences" + CoE_HSS + + "HAAS Arts and Literature" + HAAS_AL + + "HAAS Biological Sciences" + HAAS_BS + + "HAAS Historical Studies" + HAAS_HS + + "HAAS International Studies" + HAAS_IS + + "HAAS Philosophy and Values" + HAAS_PV + + "HAAS Physical Science" + HAAS_PS + + "HAAS Social and Behavioral Sciences" + HAAS_SBS + } + + """ + Not in use atm + """ + type MajorReq @cacheControl(maxAge: 0) { + name: String! + major: String! + numCoursesRequired: Int! + satisfyingCourseIds: [String!]! + isMinor: Boolean! + } + + type Label @cacheControl(maxAge: 0) { + name: String! + color: String! + } + + type Plan @cacheControl(maxAge: 0) { + _id: ID! + userEmail: String! + planTerms: [PlanTerm!]! + majorReqs: [MajorReq!]! + majors: [String!]! + minors: [String!]! + created: String! + revised: String! + colleges: [Colleges!]! + labels: [Label!]! + """ + Requirements manually satisfied + """ + uniReqsSatisfied: [UniReqs!]! + collegeReqsSatisfied: [CollegeReqs!]! + } + + type PlanTerm @cacheControl(maxAge: 0) { + _id: ID! + name: String! + userEmail: String! + year: Int! + term: Terms! + courses: [SelectedCourse!]! + hidden: Boolean! + status: Status! + pinned: Boolean! + } + + type SelectedCourse @cacheControl(maxAge: 0) { + """ + Identifiers (probably cs-course-ids) for the classes the user has added to their schedule. + """ + courseID: String! + courseName: String! + courseTitle: String! + courseUnits: Int! + uniReqs: [UniReqs!]! + collegeReqs: [CollegeReqs!]! + pnp: Boolean! + transfer: Boolean! + labels: [Label!]! + } + + input MajorReqInput { + name: String! + major: String! + numCoursesRequired: Int! + satisfyingCourseIds: [String!]! + isMinor: Boolean! + } + + input LabelInput { + name: String! + color: String! + } + + input SelectedCourseInput { + courseID: String! + courseName: String! + courseTitle: String! + courseUnits: Int! + uniReqs: [UniReqs!]! + collegeReqs: [CollegeReqs!]! + pnp: Boolean! + transfer: Boolean! + labels: [LabelInput!]! + } + + input PlanInput { + colleges: [Colleges!] + majors: [String!] + minors: [String!] + majorReqs: [MajorReqInput!] + labels: [LabelInput!] + uniReqsSatisfied: [UniReqs!] + collegeReqsSatisfied: [CollegeReqs!] + } + + input PlanTermInput { + name: String! + year: Int! + term: Terms! + courses: [SelectedCourseInput!]! + hidden: Boolean! + status: Status! + pinned: Boolean! + } + + input EditPlanTermInput { + name: String + year: Int + term: Terms + courses: [SelectedCourseInput!] + hidden: Boolean + status: Status + pinned: Boolean + } + + type Query { + """ + Takes in user's email and returns their entire plan + """ + planByUser: [Plan!]! @auth + } + + type Mutation { + """ + Takes in user's email, a college, majors, and minors, creates a new Plan record in the database, and returns the Plan + """ + createNewPlan( + colleges: [Colleges!]! + startYear: Int! + endYear: Int! + majors: [String!]! + minors: [String!]! + ): Plan @auth + + """ + Edits Plan college and majorReqs + """ + editPlan(plan: PlanInput!): Plan @auth + + """ + Takes in PlanTerm fields, creates a new PlanTerm record in the database, and returns the PlanTerm. + """ + createNewPlanTerm(planTerm: PlanTermInput!): PlanTerm @auth + + """ + Takes in a PlanTerm's ObjectID, deletes the PlanTerm with that ID, and returns the ID. + """ + removePlanTermByID(id: ID!): ID @auth + + """ + Takes in planTerm fields, find the planTerm record in the database corresponding to the provided term, + updates the record, and returns the updated planTerm + """ + editPlanTerm(id: ID!, planTerm: EditPlanTermInput!): PlanTerm @auth + + """ + For the planTerm specified by the term, modifies the courses field, and returns the updated + planTerm. + """ + setSelectedCourses(id: ID!, courses: [SelectedCourseInput!]!): PlanTerm + @auth + + """ + Deletes plan, for testing purposes + """ + deletePlan: String @auth + } +`; + +export default typeDef; diff --git a/apps/backend/src/modules/user/formatter.ts b/apps/backend/src/modules/user/formatter.ts index 9adbcbadd..a2a248128 100644 --- a/apps/backend/src/modules/user/formatter.ts +++ b/apps/backend/src/modules/user/formatter.ts @@ -18,5 +18,7 @@ export const formatUser = (user: UserType) => { student: user.email.endsWith("@berkeley.edu"), bookmarkedCourses: user.bookmarkedCourses, bookmarkedClasses: user.bookmarkedClasses, + majors: user.majors ? user.majors : [], + minors: user.minors ? user.minors : [] } as IntermediateUser; }; diff --git a/apps/backend/src/modules/user/typedefs/user.ts b/apps/backend/src/modules/user/typedefs/user.ts index 07ef2ba53..521e798b0 100644 --- a/apps/backend/src/modules/user/typedefs/user.ts +++ b/apps/backend/src/modules/user/typedefs/user.ts @@ -8,6 +8,8 @@ const typedef = gql` student: Boolean! bookmarkedCourses: [Course!]! bookmarkedClasses: [Class!]! + majors: [String!]! + minors: [String!]! } type Query { @@ -31,6 +33,8 @@ const typedef = gql` input UpdateUserInput { bookmarkedClasses: [BookmarkedClassInput!] bookmarkedCourses: [BookmarkedCourseInput!] + majors: [String!] + minors: [String!] } type Mutation { diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index b24cd3cf7..ab6fb1917 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,10 +1,4 @@ { - "extends": "@repo/typescript-config/base.json", - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "noEmit": true - }, + "extends": "@repo/typescript-config/node.json", "include": ["src"] } diff --git a/apps/datapuller/Dockerfile b/apps/datapuller/Dockerfile new file mode 100644 index 000000000..56915e4d7 --- /dev/null +++ b/apps/datapuller/Dockerfile @@ -0,0 +1,26 @@ +FROM node:alpine AS base +RUN ["npm", "install", "-g", "turbo@latest"] + +# datapuller +FROM base AS datapuller-builder +WORKDIR /datapuller +COPY . . + +RUN ["turbo", "prune", "datapuller", "--docker"] + +FROM base AS datapuller-dev +WORKDIR /datapuller + +COPY --from=datapuller-builder /datapuller/out/json/ . +COPY --from=datapuller-builder /datapuller/out/package-lock.json ./package-lock.json +RUN ["npm", "install"] + +COPY --from=datapuller-builder /datapuller/out/full/ . + +RUN ["turbo", "run", "build", "--filter=datapuller"] +ENTRYPOINT ["turbo", "run", "main", "--filter=datapuller", "--"] +CMD ["--puller=main"] + +FROM datapuller-dev AS datapuller-prod +WORKDIR /datapuller +ENTRYPOINT ["turbo", "run", "main", "--filter=datapuller", "--env-mode=loose", "--"] \ No newline at end of file diff --git a/docs/.dockerignore b/apps/docs/.dockerignore similarity index 100% rename from docs/.dockerignore rename to apps/docs/.dockerignore diff --git a/docs/.gitignore b/apps/docs/.gitignore similarity index 100% rename from docs/.gitignore rename to apps/docs/.gitignore diff --git a/docs/Dockerfile b/apps/docs/Dockerfile similarity index 65% rename from docs/Dockerfile rename to apps/docs/Dockerfile index 208713d53..bcc95dc26 100644 --- a/docs/Dockerfile +++ b/apps/docs/Dockerfile @@ -5,19 +5,19 @@ RUN ["cargo", "install", "mdbook-toc"] # dev FROM docs-deps AS docs-dev -WORKDIR /docs -COPY . . -VOLUME ["/docs"] +WORKDIR /apps/docs +COPY apps/docs ./ +VOLUME ["/apps/docs"] EXPOSE 3000 ENTRYPOINT ["mdbook", "serve", "--hostname=0.0.0.0", "--port=3000"] # prod FROM docs-deps AS docs-builder -WORKDIR /docs -COPY . . +WORKDIR /apps/docs +COPY apps/docs ./ RUN ["mdbook", "build"] FROM nginx:alpine AS docs-prod -COPY ./nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=docs-builder /docs/book /var/www/html +COPY apps/docs/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=docs-builder /apps/docs/book /var/www/html EXPOSE 80 diff --git a/docs/book.toml b/apps/docs/book.toml similarity index 100% rename from docs/book.toml rename to apps/docs/book.toml diff --git a/.storybook/nginx.conf b/apps/docs/nginx.conf similarity index 100% rename from .storybook/nginx.conf rename to apps/docs/nginx.conf diff --git a/docs/src/README.md b/apps/docs/src/README.md similarity index 100% rename from docs/src/README.md rename to apps/docs/src/README.md diff --git a/docs/src/SUMMARY.md b/apps/docs/src/SUMMARY.md similarity index 100% rename from docs/src/SUMMARY.md rename to apps/docs/src/SUMMARY.md diff --git a/docs/src/core/backend/README.md b/apps/docs/src/core/backend/README.md similarity index 100% rename from docs/src/core/backend/README.md rename to apps/docs/src/core/backend/README.md diff --git a/docs/src/core/backend/assets/backend-module.excalidraw b/apps/docs/src/core/backend/assets/backend-module.excalidraw similarity index 100% rename from docs/src/core/backend/assets/backend-module.excalidraw rename to apps/docs/src/core/backend/assets/backend-module.excalidraw diff --git a/docs/src/core/backend/assets/backend-module.svg b/apps/docs/src/core/backend/assets/backend-module.svg similarity index 100% rename from docs/src/core/backend/assets/backend-module.svg rename to apps/docs/src/core/backend/assets/backend-module.svg diff --git a/docs/src/core/datapuller/README.md b/apps/docs/src/core/datapuller/README.md similarity index 100% rename from docs/src/core/datapuller/README.md rename to apps/docs/src/core/datapuller/README.md diff --git a/docs/src/core/datapuller/local-remote-development.md b/apps/docs/src/core/datapuller/local-remote-development.md similarity index 100% rename from docs/src/core/datapuller/local-remote-development.md rename to apps/docs/src/core/datapuller/local-remote-development.md diff --git a/docs/src/core/frontend/README.md b/apps/docs/src/core/frontend/README.md similarity index 100% rename from docs/src/core/frontend/README.md rename to apps/docs/src/core/frontend/README.md diff --git a/docs/src/core/infrastructure/README.md b/apps/docs/src/core/infrastructure/README.md similarity index 100% rename from docs/src/core/infrastructure/README.md rename to apps/docs/src/core/infrastructure/README.md diff --git a/docs/src/core/infrastructure/assets/app-infra-layer.excalidraw b/apps/docs/src/core/infrastructure/assets/app-infra-layer.excalidraw similarity index 100% rename from docs/src/core/infrastructure/assets/app-infra-layer.excalidraw rename to apps/docs/src/core/infrastructure/assets/app-infra-layer.excalidraw diff --git a/docs/src/core/infrastructure/assets/app-infra-layer.svg b/apps/docs/src/core/infrastructure/assets/app-infra-layer.svg similarity index 100% rename from docs/src/core/infrastructure/assets/app-infra-layer.svg rename to apps/docs/src/core/infrastructure/assets/app-infra-layer.svg diff --git a/docs/src/core/infrastructure/assets/architecture-diagram.excalidraw b/apps/docs/src/core/infrastructure/assets/architecture-diagram.excalidraw similarity index 100% rename from docs/src/core/infrastructure/assets/architecture-diagram.excalidraw rename to apps/docs/src/core/infrastructure/assets/architecture-diagram.excalidraw diff --git a/docs/src/core/infrastructure/assets/architecture-diagram.svg b/apps/docs/src/core/infrastructure/assets/architecture-diagram.svg similarity index 100% rename from docs/src/core/infrastructure/assets/architecture-diagram.svg rename to apps/docs/src/core/infrastructure/assets/architecture-diagram.svg diff --git a/docs/src/core/infrastructure/assets/cicd-workflow.excalidraw b/apps/docs/src/core/infrastructure/assets/cicd-workflow.excalidraw similarity index 100% rename from docs/src/core/infrastructure/assets/cicd-workflow.excalidraw rename to apps/docs/src/core/infrastructure/assets/cicd-workflow.excalidraw diff --git a/docs/src/core/infrastructure/assets/cicd-workflow.svg b/apps/docs/src/core/infrastructure/assets/cicd-workflow.svg similarity index 100% rename from docs/src/core/infrastructure/assets/cicd-workflow.svg rename to apps/docs/src/core/infrastructure/assets/cicd-workflow.svg diff --git a/docs/src/core/infrastructure/cicd-workflow.md b/apps/docs/src/core/infrastructure/cicd-workflow.md similarity index 100% rename from docs/src/core/infrastructure/cicd-workflow.md rename to apps/docs/src/core/infrastructure/cicd-workflow.md diff --git a/docs/src/core/infrastructure/dns-certificates.md b/apps/docs/src/core/infrastructure/dns-certificates.md similarity index 100% rename from docs/src/core/infrastructure/dns-certificates.md rename to apps/docs/src/core/infrastructure/dns-certificates.md diff --git a/docs/src/core/infrastructure/onboarding.md b/apps/docs/src/core/infrastructure/onboarding.md similarity index 96% rename from docs/src/core/infrastructure/onboarding.md rename to apps/docs/src/core/infrastructure/onboarding.md index 606db6951..3acabe624 100644 --- a/docs/src/core/infrastructure/onboarding.md +++ b/apps/docs/src/core/infrastructure/onboarding.md @@ -87,6 +87,8 @@ This guide assumes basic experience with SSH. [Helm](https://helm.sh/) is a package manager for Kubernetes that provides an abstraction over the Kubernetes interface for deploying groups of components called "charts". In addition, it allows us to install pre-made charts, useful for deploying services that we don't develop. +[Here](https://www.figma.com/board/5EQJarZPbO1kFdMBcviZWr/Berkeleytime-Kubernetes-Cluster?node-id=0-1&t=8uTqITuoiyk1e1cq-1) is a diagram outlining (in some detail) the structure of the Kubernetes cluster. + ### Useful Commands This is an uncomprehensive list of commands that can be executed in `hozer-51`, useful for debugging. diff --git a/docs/src/core/infrastructure/runbooks.md b/apps/docs/src/core/infrastructure/runbooks.md similarity index 100% rename from docs/src/core/infrastructure/runbooks.md rename to apps/docs/src/core/infrastructure/runbooks.md diff --git a/docs/src/fa24/crowd-sourced-data/README.md b/apps/docs/src/fa24/crowd-sourced-data/README.md similarity index 100% rename from docs/src/fa24/crowd-sourced-data/README.md rename to apps/docs/src/fa24/crowd-sourced-data/README.md diff --git a/docs/src/fa24/decals/README.md b/apps/docs/src/fa24/decals/README.md similarity index 100% rename from docs/src/fa24/decals/README.md rename to apps/docs/src/fa24/decals/README.md diff --git a/docs/src/fa24/gradtrak/README.md b/apps/docs/src/fa24/gradtrak/README.md similarity index 100% rename from docs/src/fa24/gradtrak/README.md rename to apps/docs/src/fa24/gradtrak/README.md diff --git a/docs/src/fa24/semantic-search/README.md b/apps/docs/src/fa24/semantic-search/README.md similarity index 100% rename from docs/src/fa24/semantic-search/README.md rename to apps/docs/src/fa24/semantic-search/README.md diff --git a/docs/src/getting-started/assets/cicd-dev-1.png b/apps/docs/src/getting-started/assets/cicd-dev-1.png similarity index 100% rename from docs/src/getting-started/assets/cicd-dev-1.png rename to apps/docs/src/getting-started/assets/cicd-dev-1.png diff --git a/docs/src/getting-started/assets/cicd-dev-2.png b/apps/docs/src/getting-started/assets/cicd-dev-2.png similarity index 100% rename from docs/src/getting-started/assets/cicd-dev-2.png rename to apps/docs/src/getting-started/assets/cicd-dev-2.png diff --git a/docs/src/getting-started/assets/cicd-dev-3.png b/apps/docs/src/getting-started/assets/cicd-dev-3.png similarity index 100% rename from docs/src/getting-started/assets/cicd-dev-3.png rename to apps/docs/src/getting-started/assets/cicd-dev-3.png diff --git a/docs/src/getting-started/assets/cicd-dev-4.png b/apps/docs/src/getting-started/assets/cicd-dev-4.png similarity index 100% rename from docs/src/getting-started/assets/cicd-dev-4.png rename to apps/docs/src/getting-started/assets/cicd-dev-4.png diff --git a/docs/src/getting-started/assets/cicd-dev-5.png b/apps/docs/src/getting-started/assets/cicd-dev-5.png similarity index 100% rename from docs/src/getting-started/assets/cicd-dev-5.png rename to apps/docs/src/getting-started/assets/cicd-dev-5.png diff --git a/docs/src/getting-started/assets/cicd-dev-6.png b/apps/docs/src/getting-started/assets/cicd-dev-6.png similarity index 100% rename from docs/src/getting-started/assets/cicd-dev-6.png rename to apps/docs/src/getting-started/assets/cicd-dev-6.png diff --git a/docs/src/getting-started/deployment-with-cicd.md b/apps/docs/src/getting-started/deployment-with-cicd.md similarity index 100% rename from docs/src/getting-started/deployment-with-cicd.md rename to apps/docs/src/getting-started/deployment-with-cicd.md diff --git a/docs/src/getting-started/local-development.md b/apps/docs/src/getting-started/local-development.md similarity index 100% rename from docs/src/getting-started/local-development.md rename to apps/docs/src/getting-started/local-development.md diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 000000000..2a26f896c --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:alpine AS base +RUN ["npm", "install", "-g", "turbo@latest"] + +FROM base AS frontend-builder +WORKDIR /frontend +COPY . . +RUN ["turbo", "prune", "frontend", "--docker"] + +FROM base AS frontend-dev +WORKDIR /frontend + +COPY --from=frontend-builder /frontend/out/json/ . +COPY --from=frontend-builder /frontend/out/package-lock.json ./package-lock.json +RUN ["npm", "install"] + +COPY --from=frontend-builder /frontend/out/full/ . +ENTRYPOINT ["turbo", "run", "dev", "--filter=frontend"] + +FROM frontend-dev AS frontend-prod +RUN ["turbo", "run", "build", "--filter=frontend", "--env-mode=loose"] + +ENTRYPOINT ["turbo", "run", "start", "--filter=frontend"] \ No newline at end of file diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c139bfa8b..92ceac7a9 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -9,39 +9,39 @@ "start": "serve -s dist -p 3000" }, "dependencies": { - "@apollo/client": "3.13.9", - "@floating-ui/dom": "^1.7.3", + "@apollo/client": "4.0.7", + "@floating-ui/dom": "^1.7.4", "@mapbox/mapbox-gl-directions": "^4.3.1", "@repo/shared": "*", "@repo/theme": "*", "@shopify/draggable": "^1.1.4", "@tanstack/react-virtual": "^3.13.12", - "babel-plugin-react-compiler": "^19.1.0-rc.2", "classnames": "^2.5.1", "fuse.js": "^7.1.0", "graphql": "^16.11.0", "iconoir-react": "^7.11.0", - "mapbox-gl": "^3.14.0", + "mapbox-gl": "^3.15.0", "moment": "^2.30.1", - "radix-ui": "^1.4.2", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.7.1", + "radix-ui": "^1.4.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.3", "react-select": "^5.10.2", - "recharts": "^3.1.0" + "recharts": "^3.2.1", + "rxjs": "^7.8.2" }, "devDependencies": { "@repo/eslint-config": "*", "@repo/typescript-config": "*", "@types/lodash": "^4.17.20", "@types/mapbox-gl": "^3.4.1", - "@types/node": "^24.1.0", - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", - "@vitejs/plugin-react": "^4.7.0", - "eslint": "^9.32.0", - "serve": "^14.2.4", - "typescript": "^5.8.3", - "vite": "^7.0.6" + "@types/node": "^24.7.0", + "@types/react": "^19.2.1", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.37.0", + "serve": "^14.2.5", + "typescript": "^5.9.3", + "vite": "^7.1.9" } } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 7f9363b13..e062cb6cb 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { lazy } from "react"; -import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client"; +import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import { ApolloProvider } from "@apollo/client/react"; import { RouterProvider, createBrowserRouter, @@ -41,7 +42,7 @@ const Course = { const Catalog = lazy(() => import("@/app/Catalog")); const Enrollment = lazy(() => import("@/app/Enrollment")); const GradeDistributions = lazy(() => import("@/app/GradeDistributions")); -const About = lazy(() => import("@/app/About")); +// const About = lazy(() => import("@/app/About")); // const Discover = lazy(() => import("@/app/Discover")); const Plan = lazy(() => import("@/app/Plan")); const Schedule = lazy(() => import("@/app/Schedule")); @@ -51,6 +52,10 @@ const Schedules = lazy(() => import("@/app/Schedules")); // const Map = lazy(() => import("@/app/Map")); const Plans = lazy(() => import("@/app/Plans")); +const GradTrak = lazy(() => import("@/app/GradTrak")); +const GradTrakOnboarding = lazy(() => import("@/app/GradTrak/Onboarding")); +const GradTrakDashboard = lazy(() => import("@/app/GradTrak/Dashboard")); + const router = createBrowserRouter([ { element: , @@ -109,15 +114,35 @@ const router = createBrowserRouter([ ], }, { - element: , + element: , children: [ { + path: "gradtrak", element: ( - - + + ), - path: "about", + }, + { + path: "gradtrak/onboarding", + element: ( + + + + ), + }, + { + path: "gradtrak/dashboard", + element: ( + + + + ), + }, + { + path: "*", + loader: () => redirect("/gradtrak"), }, ], }, @@ -312,8 +337,22 @@ const router = createBrowserRouter([ ]); const client = new ApolloClient({ - uri: "/api/graphql", - cache: new InMemoryCache(), + link: new HttpLink({ + uri: "/api/graphql", + }), + cache: new InMemoryCache({ + typePolicies: { + PlanTerm: { + fields: { + courses: { + merge(_, incoming) { + return incoming; + }, + }, + }, + }, + }, + }), }); export default function App() { diff --git a/apps/frontend/src/app/Catalog/Dashboard/index.tsx b/apps/frontend/src/app/Catalog/Dashboard/index.tsx index 058f9da86..11b8c30c3 100644 --- a/apps/frontend/src/app/Catalog/Dashboard/index.tsx +++ b/apps/frontend/src/app/Catalog/Dashboard/index.tsx @@ -7,7 +7,7 @@ import { useState, } from "react"; -import { useApolloClient } from "@apollo/client"; +import { useApolloClient } from "@apollo/client/react"; import { ArrowSeparateVertical, BookmarkSolid, @@ -90,7 +90,7 @@ export default function Dashboard({ }, }); - return response.data.class; + return response.data?.class; } catch { // TODO: Handle errors diff --git a/apps/frontend/src/app/Discover/index.tsx b/apps/frontend/src/app/Discover/index.tsx index 15764eacc..131574866 100644 --- a/apps/frontend/src/app/Discover/index.tsx +++ b/apps/frontend/src/app/Discover/index.tsx @@ -1,6 +1,6 @@ import { FormEvent, useCallback, useMemo, useState } from "react"; -import { useQuery } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import { ArrowRight } from "iconoir-react"; import { Button } from "@repo/theme"; diff --git a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx index 3fbb3331d..765504ce0 100644 --- a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx +++ b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction, useMemo, useRef, useState } from "react"; -import { useApolloClient } from "@apollo/client"; +import { useApolloClient } from "@apollo/client/react"; import { useSearchParams } from "react-router-dom"; import { Box, Button, Flex, Select, SelectHandle } from "@repo/theme"; @@ -84,7 +84,7 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { return filteredOptions; } return [...list, ...filteredOptions]; - }, [courseData]); + }, [courseData, selectedSemester]); const getClassOptions = ( semester: string | null = null, @@ -133,7 +133,11 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { return [...list, ...opts]; }; - const classOptions = useMemo(getClassOptions, [courseData, selectedSemester]); + const classOptions = useMemo(getClassOptions, [ + courseData, + selectedClass, + selectedSemester, + ]); const add = async () => { if (!selectedClass || !selectedCourse || !selectedSemester) return; @@ -178,7 +182,8 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { hidden: false, active: false, color: LIGHT_COLORS[outputs.length], - enrollmentHistory: response.data.enrollment, + // TODO: Error handling + enrollmentHistory: response.data!.enrollment, input, }; diff --git a/apps/frontend/src/app/Enrollment/Enrollment.module.scss b/apps/frontend/src/app/Enrollment/Enrollment.module.scss index a10a56170..562db28de 100644 --- a/apps/frontend/src/app/Enrollment/Enrollment.module.scss +++ b/apps/frontend/src/app/Enrollment/Enrollment.module.scss @@ -25,6 +25,7 @@ color: var(--paragraph-color); } } + .empty { position: absolute; color: var(--label-color); @@ -36,4 +37,4 @@ margin-inline: auto; width: fit-content; } -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/Enrollment/index.tsx b/apps/frontend/src/app/Enrollment/index.tsx index e8083b038..21b61e7ac 100644 --- a/apps/frontend/src/app/Enrollment/index.tsx +++ b/apps/frontend/src/app/Enrollment/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useApolloClient } from "@apollo/client"; +import { useApolloClient } from "@apollo/client/react"; import { FrameAltEmpty } from "iconoir-react"; import { useSearchParams } from "react-router-dom"; import { @@ -111,7 +111,8 @@ export default function Enrollment() { response ? acc.concat({ color: LIGHT_COLORS[index], - enrollmentHistory: response.data.enrollment, + // TODO: Error handling + enrollmentHistory: response.data!.enrollment, input: initialInputs[index], active: false, hidden: false, @@ -206,7 +207,7 @@ export default function Enrollment() { if (outputs.length > 0) { if (!hoveredSeries) setHoveredSeries(0); } else setHoveredSeries(null); - }, [outputs]); + }, [hoveredSeries, outputs]); const dataMax = useMemo(() => { return ( diff --git a/apps/frontend/src/app/GradTrak/Dashboard/AddBlockMenu/AddBlockMenu.module.scss b/apps/frontend/src/app/GradTrak/Dashboard/AddBlockMenu/AddBlockMenu.module.scss new file mode 100644 index 000000000..5b1dd42aa --- /dev/null +++ b/apps/frontend/src/app/GradTrak/Dashboard/AddBlockMenu/AddBlockMenu.module.scss @@ -0,0 +1,76 @@ +.addBlockMenu { + width: 318px; + background-color: var(--foreground-color); + position: absolute; + right: 30px; + top: 80px; + box-shadow: 0 8px 24px rgba(0,0,0,0.28); + border-radius: 8px; + + .section { + border-bottom: 1px solid var(--border-color); + padding: 6px; + font-size: 14px; + font-weight: 500; + color: var(--heading-color); + + &:last-child { + margin-bottom: 0; + border: none; + } + + .inputContainer { + display: flex; + align-items: center; + width: 100%; + border-radius: 0.25rem; + gap: 12px; + + .confirmButton { + width: 28%; + display: flex; + justify-content: center; + } + } + + + .sectionBubble { + padding: 10px; + border-radius: 6px; + + .sectionTitle { + margin-bottom: 12px; + } + } + + .sectionBubbleButton { + padding: 12px; + border-radius: 4px; + display: flex; + justify-content: space-between; + cursor: pointer; + &:hover { + background-color: var(--background-color); + + } + } + } +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: 12px; +} + +.arrow { + width: 16px; + height: 16px; + color: var(--text-color-secondary); +} + +.confirmButton { + width: 100%; + display: flex; + justify-content: center; +} \ No newline at end of file diff --git a/apps/frontend/src/app/GradTrak/Dashboard/AddBlockMenu/index.tsx b/apps/frontend/src/app/GradTrak/Dashboard/AddBlockMenu/index.tsx new file mode 100644 index 000000000..a11c4f835 --- /dev/null +++ b/apps/frontend/src/app/GradTrak/Dashboard/AddBlockMenu/index.tsx @@ -0,0 +1,212 @@ +import { useEffect, useRef, useState } from "react"; + +import { ArrowLeft, LongArrowDownLeft, NavArrowRight } from "iconoir-react"; + +import { Button, Input, Select } from "@repo/theme"; + +import { PlanTermInput, Status, Terms } from "@/lib/api"; + +import styles from "./AddBlockMenu.module.scss"; + +type AddBlockMenuProps = { + onClose: () => void; + createNewPlanTerm: (planTerm: PlanTermInput) => void; +}; + +export default function AddBlockMenu({ + onClose, + createNewPlanTerm, +}: AddBlockMenuProps) { + const [activeMenu, setActiveMenu] = useState<"main" | "semester" | "custom">( + "main" + ); + const [selectedTerm, setSelectedTerm] = useState(null); + const [selectedYear, setSelectedYear] = useState(null); + const containerRef = useRef(null); + const [customName, setCustomName] = useState(""); + + const currentYear = new Date().getFullYear(); + const yearOptions = Array.from( + { length: currentYear + 5 - 2020 + 1 }, + (_, i) => ({ + label: (2020 + i).toString(), + value: 2020 + i, + }) + ); + + const semesterOptions = [ + { label: "Fall", value: "Fall" }, + { label: "Spring", value: "Spring" }, + { label: "Summer", value: "Summer" }, + ]; + + const handleSemesterSelect = (value: string | string[] | null) => { + if (value === null || Array.isArray(value)) { + return; + } else { + setSelectedTerm(value); + } + }; + + const handleYearSelect = (value: number | number[] | null) => { + if (value === null || Array.isArray(value)) { + return; + } else { + setSelectedYear(value); + } + }; + + const handleSubmitSemester = () => { + createNewPlanTerm({ + name: `${selectedTerm} ${selectedYear}`, + year: selectedYear ? selectedYear : currentYear, + term: selectedTerm ? (selectedTerm as Terms) : Terms.Fall, + hidden: false, + status: Status.Incomplete, + pinned: false, + courses: [], + }); + setSelectedTerm(null); + setSelectedYear(null); + setActiveMenu("main"); + onClose?.(); + }; + + const handleSubmitCustom = () => { + createNewPlanTerm({ + name: customName, + year: -1, + term: Terms.Misc, + hidden: false, + status: Status.Incomplete, + pinned: false, + courses: [], + }); + setCustomName(""); + setActiveMenu("main"); + onClose?.(); + }; + + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + const el = containerRef.current; + if (!el) return; + if (e.target instanceof Node && !el.contains(e.target)) { + onClose?.(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose?.(); + }; + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + return ( +
+ {activeMenu == "main" ? ( +
+
+
{ + setActiveMenu("semester"); + }} + > + New Semester Block + +
+
+
+
{ + setActiveMenu("custom"); + }} + > + Create Custom Block + +
+
+
+ ) : activeMenu == "semester" ? ( +
+
+
+ setActiveMenu("main")} + style={{ cursor: "pointer" }} + /> + New Semester Block +
+
+ + +
+
+
+ ) : ( +
+
+
+ setActiveMenu("main")} + style={{ cursor: "pointer" }} + /> + New custom block +
+
+
+ setCustomName(e.target.value)} + /> +
+ +
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/app/GradTrak/Dashboard/Dashboard.module.scss b/apps/frontend/src/app/GradTrak/Dashboard/Dashboard.module.scss new file mode 100644 index 000000000..469f581ce --- /dev/null +++ b/apps/frontend/src/app/GradTrak/Dashboard/Dashboard.module.scss @@ -0,0 +1,192 @@ +.root { + flex: 1 1 0; + display: flex; + min-height: 0; + color: var(--heading-color); + + .view { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; + max-width: 78%; + scrollbar-color: var(--paragraph-color) var(--border-color, rgba(0, 0, 0, 0.10)); + + .header { + display: flex; + flex-shrink: 0; + justify-content: space-between; + align-items: center; + align-self: stretch; + padding: 2rem 2rem 0 2rem; + + .buttons-group { + display: flex; + gap: 0.625rem; + + .filter-button-container { + position: relative; + display: inline-block; + + .filter-button { + &[data-open="true"] { + background-color: #52525B; + } + } + + .filter-button-badge { + position: absolute; + top: -6px; + right: -6px; + background-color: #3B82F6; + color: white; + border-radius: 12px; + min-width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + padding: 0 6px; + box-sizing: border-box; + } + } + + } + } + } + + .panel { + display: flex; + flex-direction: column; + overflow: scroll; + width: 27%; + } + + @media (width > 600px) { + .panel { + border-right: 1px solid var(--border-color); + } + + .view { + flex-grow: 1; + } + } + + @media (width <= 600px) { + .panel { + flex-grow: 1; + } + } +} + +.semester-blocks { + display: flex; + flex-grow: 1; + flex-direction: column; + overflow-x: auto; + overflow-y: auto; + padding: 2rem; + gap: 2rem; +} + +.semester-layout { + display: flex; + gap: 0.75rem; + + &[data-layout="grid"] { + display: grid; + grid-template-columns: repeat(2, 1fr); /* exactly 2 columns */ + grid-auto-flow: row; /* fill rows first */ + gap: 10px; + } +} + +.sortMenu { + cursor: pointer; + align-items: center; + background-color: transparent !important; + + .option { + display: flex; + align-items: center; + cursor: pointer; + width: 100%; + justify-content: flex-start; + padding: 6px 14px; + } + + .circle { + width: 1.125rem; + height: 1.125rem; + border: 1.5px solid #C7C7C7; + border-radius: 50%; + margin-right: 1rem; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + .dot { + width: 0.5175rem; + height: 0.5175rem; + background-color: var(--blue-500); + border-radius: 50%; + } + } + + .input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } +} + +.menuText { + padding-left: 10px; +} + +.menuItem { + &:hover { + background-color: var(--button-hover-color) !important; + .menuText { + color: var(--heading-color); + } + } + + &.selected { + background-color: var(--button-active-color); + .menuText, .menuIcon { + color: var(--heading-color); + } + } +} + +.filters-dropdown { + width: max-content; + padding: 16px; + padding-top: 0px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + + .label-count { + color: var(--label-color); + } + .filter-option-text { + &[data-selected="true"] { + color: var(--heading-color); + } + + &[data-selected="false"] { + color: var(--paragraph-color); + } + } + + .sectionTitle { + font-size: 14px; + font-weight: 500; + margin-top: 12px; + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/GradTrak/Dashboard/DisplayMenu/DisplayMenu.module.scss b/apps/frontend/src/app/GradTrak/Dashboard/DisplayMenu/DisplayMenu.module.scss new file mode 100644 index 000000000..7176520ba --- /dev/null +++ b/apps/frontend/src/app/GradTrak/Dashboard/DisplayMenu/DisplayMenu.module.scss @@ -0,0 +1,186 @@ +.displayMenu { + width: 318px; + background-color: var(--foreground-color); + position: absolute; + right: 30px; + top: 80px; + box-shadow: 0 8px 24px rgba(0,0,0,0.28); + border-radius: 8px; + + .section { + border-bottom: 1px solid var(--border-color); + padding: 6px; + font-size: 14px; + font-weight: 500; + color: var(--heading-color); + + &:last-child { + margin-bottom: 0; + border: none; + } + + .sectionBubble { + padding: 10px; + border-radius: 6px; + + .sectionTitle { + margin-bottom: 12px; + } + } + + .sectionBubbleButton { + padding: 12px; + border-radius: 4px; + display: flex; + justify-content: space-between; + cursor: pointer; + &:hover { + background-color: var(--background-color); + + } + } + } +} + +.arrow { + width: 16px; + height: 16px; + color: var(--text-color-secondary); +} + +// Layout Options +.layoutOptions { + display: flex; + gap: 8px; +} + +.layoutOption { + width: 60px; + height: 40px; + border: 2px solid var(--border-color); + border-radius: 6px; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background-color: var(--button-active-color); + } + + &.selected { + border-color: var(--blue-500); + background-color: var(--button-active-color) + } +} + +.chartIcon { + display: flex; + align-items: start; + gap: 2px; + height: 20px; +} + +.bar { + width: 8px; + background: var(--paragraph-color); + border-radius: 1px; + transition: all 0.2s ease; +} + +.gridIcon { + display: flex; + flex-direction: column; + gap: 2px; +} + +.gridRow { + display: flex; + gap: 2px; +} + +.gridItem { + width: 14px; + height: 6px; + background: var(--paragraph-color); + border-radius: 1px; +} + +// Toggle Switches +.toggleGroup { + display: flex; + flex-direction: column; + gap: 12px; +} + +.toggleItem { + display: flex; + align-items: center; + justify-content: space-between; + .toggleItemText { + color: var(--heading-color); + } +} + +.toggle { + width: 44px; + height: 24px; + border-radius: 12px; + border: none; + cursor: pointer; + position: relative; + transition: all 0.2s ease; + + &.toggleOff { + background: var(--border-color); + } + + &.toggleOn { + background: var(--primary-color); + } +} + +.toggleCircle { + width: 20px; + height: 20px; + border-radius: 50%; + background: white; + position: absolute; + top: 2px; + transition: all 0.2s ease; + + .toggleOff & { + left: 2px; + } + + .toggleOn & { + left: 22px; + } +} + +// Labels +.labelsContainer { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.addLabel { + width: 27px; + height: 27px; + font-size: 12px; +} +.eye { + color: var(--paragraph-color); + padding: 4px; + border-radius: 4px; + width: 24px; + height: 24px; + cursor: pointer; + &:hover { + background-color: var(--button-hover-color); + color: var(--label-color); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/GradTrak/Dashboard/DisplayMenu/index.tsx b/apps/frontend/src/app/GradTrak/Dashboard/DisplayMenu/index.tsx new file mode 100644 index 000000000..bb08c696c --- /dev/null +++ b/apps/frontend/src/app/GradTrak/Dashboard/DisplayMenu/index.tsx @@ -0,0 +1,276 @@ +import { useEffect, useRef, useState } from "react"; + +import classNames from "classnames"; +import { + ArrowLeft, + Eye, + EyeClosed, + Hashtag, + Label, + NavArrowRight, + Plus, + Reports, +} from "iconoir-react"; + +import { + BadgeLabel, + Color, + Flex, + IconButton, + Switch, + Text, + Tooltip, +} from "@repo/theme"; + +import { ILabel } from "@/lib/api"; + +import { GradTrakSettings, ShowSetting } from "../settings"; +import styles from "./DisplayMenu.module.scss"; + +type DisplayMenuProps = { + onClose: () => void; + settings: GradTrakSettings; + onChangeSettings: (patch: Partial) => void; + triggerRef: React.RefObject; + labels: ILabel[]; + setShowLabelMenu: React.Dispatch>; +}; + +const SETTING_KEY_TO_DETAILS = { + [ShowSetting.units]: { + label: "Units", + icon: , + }, + [ShowSetting.grading]: { + label: "Grades", + icon: , + }, + [ShowSetting.labels]: { + label: "Labels", + icon: