diff --git a/apps/availability/.env.local.template b/apps/availability/.env.local.template new file mode 100644 index 0000000..7ce2697 --- /dev/null +++ b/apps/availability/.env.local.template @@ -0,0 +1,9 @@ +# Airtable: https://support.airtable.com/docs/creating-and-using-api-keys-and-access-tokens +AIRTABLE_PERSONAL_ACCESS_TOKEN= + +# BlueDot Impact Slack, #tech-dev-alerts +ALERTS_SLACK_CHANNEL_ID=C04SFUECECU +# For (local) BlueBot see https://airtable.com/appnNmNoNMB6crg6I/tbllthJ2YSPDsKWCt/viwfjWLvplq6Xw93D/recqurdJTPWzm5y4W +# For (prod) BlueBot see https://api.slack.com/apps/A04PV2GAQRY/install-on-team +# Starts 'xoxb-' +ALERTS_SLACK_BOT_TOKEN= diff --git a/apps/availability/.env.test b/apps/availability/.env.test new file mode 100644 index 0000000..8fb6ba2 --- /dev/null +++ b/apps/availability/.env.test @@ -0,0 +1,6 @@ +# Airtable +AIRTABLE_PERSONAL_ACCESS_TOKEN=FAKE_TOKEN + +# Slack +ALERTS_SLACK_CHANNEL_ID=C04SFUECECU +ALERTS_SLACK_BOT_TOKEN=FAKE_TOKEN diff --git a/apps/availability/Dockerfile b/apps/availability/Dockerfile new file mode 100644 index 0000000..3a42323 --- /dev/null +++ b/apps/availability/Dockerfile @@ -0,0 +1,25 @@ +FROM node:lts-alpine@sha256:7e227295e96f5b00aa79555ae166f50610940d888fc2e321cf36304cbd17d7d6 AS base + +RUN apk update && apk add --no-cache dumb-init + +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown node:node .next + +ARG APP_NAME +ENV APP_NAME=${APP_NAME} + +COPY --chown=node:node .next/standalone ./ +COPY --chown=node:node public ./apps/${APP_NAME}/public +COPY --chown=node:node .next/static ./apps/${APP_NAME}/.next/static + +USER node + +EXPOSE 8080 + +CMD HOSTNAME="0.0.0.0" PORT="8080" dumb-init node ./apps/${APP_NAME}/server.js diff --git a/apps/availability/next-env.d.ts b/apps/availability/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/availability/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/availability/next.config.js b/apps/availability/next.config.js new file mode 100644 index 0000000..607f8d2 --- /dev/null +++ b/apps/availability/next.config.js @@ -0,0 +1,41 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + transpilePackages: ['@bluedot/ui'], + reactStrictMode: true, + output: 'standalone', + + // We already run eslint as a separate step + eslint: { + ignoreDuringBuilds: true, + }, + + poweredByHeader: false, + headers: async () => { + return [ + // See https://developers.zoom.us/docs/meeting-sdk/web/sharedarraybuffer/ + { + source: '/', + headers: [ + { + key: 'Cross-Origin-Embedder-Policy', + value: 'require-corp', + }, + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin', + }, + ], + }, + { + source: '/:path*', + headers: [ + { + key: 'X-Bluedot-Version', + // eslint-disable-next-line turbo/no-undeclared-env-vars + value: process.env.VERSION_TAG || 'unknown', + }, + ], + }, + ]; + }, +}; diff --git a/apps/availability/package.json b/apps/availability/package.json new file mode 100644 index 0000000..db6896c --- /dev/null +++ b/apps/availability/package.json @@ -0,0 +1,46 @@ +{ + "name": "@bluedot/availability", + "version": "1.0.0", + "private": true, + "scripts": { + "postinstall": "shx cp -n .env.local.template .env.local", + "start": "next dev -p 8000", + "build": "next build", + "lint": "eslint .", + "test": "vitest --run", + "test:watch": "vitest", + "deploy:prod": "tools/deployDocker.sh" + }, + "dependencies": { + "@bluedot/ui": "*", + "airtable-ts": "^1.0.0", + "axios": "^1.6.8", + "clsx": "^2.1.0", + "http-errors": "^2.0.0", + "next": "^14.0.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.51.3", + "react-select": "^5.8.0", + "weekly-availabilities": "^1.0.0" + }, + "devDependencies": { + "@bluedot/eslint-config": "*", + "@bluedot/typescript-config": "*", + "@testing-library/react": "^15.0.2", + "@types/node": "^20.10.3", + "@types/react": "^18.0.22", + "@types/react-dom": "^18.0.7", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "dotenv": "^16.4.5", + "eslint": "^8.53.0", + "happy-dom": "^14.7.1", + "node-mocks-http": "^1.14.1", + "postcss": "^8.4.38", + "shx": "^0.3.4", + "tailwindcss": "^3.4.3", + "typescript": "^5.3.2", + "vitest": "^1.5.0" + } +} diff --git a/apps/availability/postcss.config.js b/apps/availability/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/availability/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/availability/public/fonts/RecklessNeue-Light.woff2 b/apps/availability/public/fonts/RecklessNeue-Light.woff2 new file mode 100644 index 0000000..dce4437 Binary files /dev/null and b/apps/availability/public/fonts/RecklessNeue-Light.woff2 differ diff --git a/apps/availability/public/fonts/RecklessNeue-LightItalic.woff2 b/apps/availability/public/fonts/RecklessNeue-LightItalic.woff2 new file mode 100644 index 0000000..176d66e Binary files /dev/null and b/apps/availability/public/fonts/RecklessNeue-LightItalic.woff2 differ diff --git a/apps/availability/public/fonts/Roobert-Bold.woff2 b/apps/availability/public/fonts/Roobert-Bold.woff2 new file mode 100644 index 0000000..6bcc13c Binary files /dev/null and b/apps/availability/public/fonts/Roobert-Bold.woff2 differ diff --git a/apps/availability/public/fonts/Roobert-Light.woff2 b/apps/availability/public/fonts/Roobert-Light.woff2 new file mode 100644 index 0000000..183bcc9 Binary files /dev/null and b/apps/availability/public/fonts/Roobert-Light.woff2 differ diff --git a/apps/availability/public/fonts/Roobert-Regular.woff2 b/apps/availability/public/fonts/Roobert-Regular.woff2 new file mode 100644 index 0000000..78791db Binary files /dev/null and b/apps/availability/public/fonts/Roobert-Regular.woff2 differ diff --git a/apps/availability/src/components/SpinnerIcon.tsx b/apps/availability/src/components/SpinnerIcon.tsx new file mode 100644 index 0000000..9cee905 --- /dev/null +++ b/apps/availability/src/components/SpinnerIcon.tsx @@ -0,0 +1,24 @@ +export const SpinnerIcon: React.FC = () => { + return ( + + + + + ); +}; diff --git a/apps/availability/src/globals.css b/apps/availability/src/globals.css new file mode 100644 index 0000000..fe54e1f --- /dev/null +++ b/apps/availability/src/globals.css @@ -0,0 +1,75 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@font-face { + font-family: "Reckless Neue"; + src: url("/fonts/RecklessNeue-Light.woff2") format("woff2"); + font-weight: 300; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-style: normal +} + +@font-face { + font-family: "Reckless Neue"; + src: url("/fonts/RecklessNeue-LightItalic.woff2") format("woff2"); + font-weight: 300; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-style: italic +} + +@font-face { + font-family: "Roobert"; + src: url("/fonts/Roobert-Light.woff2") format("woff2"); + font-weight: 300; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-style: normal +} + +@font-face { + font-family: "Roobert"; + src: url("/fonts/Roobert-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-style: normal +} + +@font-face { + font-family: "Roobert"; + src: url("/fonts/Roobert-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-style: normal +} + +/* TODO: move these styles out of here */ + +.setup input { + @apply px-3 py-2 text-gray-700 rounded border focus:outline focus:outline-gray-400; +} + +.setup button { + @apply px-3 py-1 rounded text-white bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300; +} + +.timezone-select.solid-select-container { + @apply w-32; +} + +.timezone-select .solid-select-list { + @apply bg-white border-gray-300 border-2 w-32; +} + +.timezone-select .solid-select-option { + @apply flex justify-center cursor-pointer; +} diff --git a/apps/availability/src/lib/api/apiRoute.ts b/apps/availability/src/lib/api/apiRoute.ts new file mode 100644 index 0000000..0c30099 --- /dev/null +++ b/apps/availability/src/lib/api/apiRoute.ts @@ -0,0 +1,37 @@ +import createHttpError from 'http-errors'; +import { NextApiHandler } from 'next'; +import { slackAlert } from './slackAlert'; + +export const apiRoute = (handler: NextApiHandler, useAuth: true | 'insecure_no_auth' = true): NextApiHandler => async (req, res) => { + try { + if (useAuth !== 'insecure_no_auth') { + const token = req.headers.authorization?.slice('Bearer '.length).trim(); + if (!token) { + throw new createHttpError.Unauthorized('Missing token'); + } + throw new Error('Authenticated endpoints not supported'); + } + await handler(req, res); + } catch (err: unknown) { + if (createHttpError.isHttpError(err) && err.expose) { + console.warn(`Error handling request on route ${req.method} ${req.url}:`); + console.warn(err); + res.status(err.statusCode).json({ error: err.message }); + return; + } + + console.error(`Internal error handling request on route ${req.method} ${req.url}:`); + console.error(err); + try { + await slackAlert([ + `Error: Failed request on route ${req.method} ${req.url}: ${err instanceof Error ? err.message : String(err)}`, + ...(err instanceof Error ? [`Stack:\n\`\`\`${err.stack}\`\`\``] : []), + ]); + } catch (slackError) { + console.error('Failed to send Slack', slackError); + } + res.status(createHttpError.isHttpError(err) ? err.statusCode : 500).json({ + error: 'Internal Server Error', + }); + } +}; diff --git a/apps/availability/src/lib/api/db.ts b/apps/availability/src/lib/api/db.ts new file mode 100644 index 0000000..965b536 --- /dev/null +++ b/apps/availability/src/lib/api/db.ts @@ -0,0 +1,25 @@ +import { AirtableTs, Table, Item } from 'airtable-ts'; +import env from './env'; + +export default new AirtableTs({ + apiKey: env.AIRTABLE_PERSONAL_ACCESS_TOKEN, +}); + +export interface FormConfiguration extends Item { + 'Slug': string, + 'Title': string, + 'Webhook': string, + 'Minimum length': number, +} + +export const formConfigurationTable: Table = { + name: 'form configuration', + baseId: 'app6dkBHka8c4WaEj', + tableId: 'tblvsaRl69XV8azGZ', + schema: { + Slug: 'string', + Title: 'string', + Webhook: 'string', + 'Minimum length': 'number', + }, +}; diff --git a/apps/availability/src/lib/api/env.ts b/apps/availability/src/lib/api/env.ts new file mode 100644 index 0000000..054d86b --- /dev/null +++ b/apps/availability/src/lib/api/env.ts @@ -0,0 +1,14 @@ +import { validateEnv } from '../validateEnv'; + +const envVars = [ + 'AIRTABLE_PERSONAL_ACCESS_TOKEN', + + 'ALERTS_SLACK_CHANNEL_ID', + 'ALERTS_SLACK_BOT_TOKEN', +] as const; + +export type Env = Record<(typeof envVars)[number], string>; + +const env: Env = validateEnv(process.env, envVars); + +export default env; diff --git a/apps/availability/src/lib/api/slackAlert.ts b/apps/availability/src/lib/api/slackAlert.ts new file mode 100644 index 0000000..1a67bdd --- /dev/null +++ b/apps/availability/src/lib/api/slackAlert.ts @@ -0,0 +1,34 @@ +import axios from 'axios'; +import env from './env'; + +export const slackAlert = async (messages: string[]): Promise => { + if (messages.length === 0) return; + const res = await sendSingleSlackMessage(messages[0]!); + for (let i = 1; i < messages.length; i++) { + // eslint-disable-next-line no-await-in-loop + await sendSingleSlackMessage(messages[i]!, res.ts); + } +}; + +const sendSingleSlackMessage = async (message: string, threadTs?: string): Promise<{ ts: string }> => { + console.log(`Sending Slack (thread: ${threadTs ?? 'none'}): ${message}`); + return axios({ + method: 'post', + baseURL: 'https://slack.com/api/', + url: 'chat.postMessage', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.ALERTS_SLACK_BOT_TOKEN}`, + }, + data: { + channel: env.ALERTS_SLACK_CHANNEL_ID, + text: `time-availability-form: ${message}`, + thread_ts: threadTs, + }, + }).then((res) => { + if (!res.data.ok) { + throw new Error(`Error from Slack API: ${res.data.error}`); + } + return { ts: res.data.ts }; + }); +}; diff --git a/apps/availability/src/lib/date.test.ts b/apps/availability/src/lib/date.test.ts new file mode 100644 index 0000000..416b2dd --- /dev/null +++ b/apps/availability/src/lib/date.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; +import { offsets, parseOffsetFromStringToMinutes } from './date'; + +describe('parseOffsetFromStringToMinutes', () => { + test.each([ + ['UTC00:00', 0], + ['UTC-01:00', 60], + ['UTC-01:30', 90], + ['UTC+01:00', -60], + ['UTC+02:00', -120], + ['UTC+03:45', -225], + ])('%s', (timezone, offset) => { + expect(parseOffsetFromStringToMinutes(timezone)).toBe(offset); + }); + + test('all offsets can be parsed', () => { + offsets.forEach((offset) => expect(() => parseOffsetFromStringToMinutes(offset)).not.toThrow()); + }); +}); diff --git a/apps/availability/src/lib/date.ts b/apps/availability/src/lib/date.ts new file mode 100644 index 0000000..28b0edd --- /dev/null +++ b/apps/availability/src/lib/date.ts @@ -0,0 +1,50 @@ +export const offsets = [ + 'UTC-12:00', + 'UTC-11:00', + 'UTC-10:00', + 'UTC-09:30', + 'UTC-09:00', + 'UTC-08:00', + 'UTC-07:00', + 'UTC-06:00', + 'UTC-05:00', + 'UTC-04:00', + 'UTC-03:30', + 'UTC-03:00', + 'UTC-02:00', + 'UTC-01:00', + 'UTC00:00', + 'UTC+01:00', + 'UTC+02:00', + 'UTC+03:00', + 'UTC+03:30', + 'UTC+04:00', + 'UTC+04:30', + 'UTC+05:00', + 'UTC+05:30', + 'UTC+05:45', + 'UTC+06:00', + 'UTC+06:30', + 'UTC+07:00', + 'UTC+08:00', + 'UTC+08:45', + 'UTC+09:00', + 'UTC+09:30', + 'UTC+10:00', + 'UTC+10:30', + 'UTC+11:00', + 'UTC+12:00', + 'UTC+12:45', + 'UTC+13:00', + 'UTC+14:00', +]; + +export function parseOffsetFromStringToMinutes(offset: string): number { + if (offset === 'UTC00:00') return 0; + + if (!/UTC(\+|-)\d\d:\d\d/.test(offset)) throw new Error(`Unsupported timezone: ${offset}`); + + const sign = offset[3] === '-' ? 1 : -1; + const minutes = (parseInt(offset[4]!) * 10 + parseInt(offset[5]!)) * 60 + (parseInt(offset[7]!) * 10 + parseInt(offset[8]!)); + return sign * minutes; +} diff --git a/apps/availability/src/lib/util.ts b/apps/availability/src/lib/util.ts new file mode 100644 index 0000000..d1fc013 --- /dev/null +++ b/apps/availability/src/lib/util.ts @@ -0,0 +1,18 @@ +export function snapToRect( + { + top, bottom, left, right, + }: { top: number, bottom: number, left: number, right: number }, + { x, y }: { x: number, y: number }, +) { + return { + // eslint-disable-next-line no-nested-ternary + x: x < left ? left + 5 : x > right ? right - 5 : x, + // eslint-disable-next-line no-nested-ternary + y: y > bottom ? bottom - 5 : y < top ? top + 5 : y, + }; +} + +// pad number with zeros so that it has 2 digits +export function pad(num: number) { + return num < 10 ? `0${num}` : num; +} diff --git a/apps/availability/src/lib/validateEnv.ts b/apps/availability/src/lib/validateEnv.ts new file mode 100644 index 0000000..162bc14 --- /dev/null +++ b/apps/availability/src/lib/validateEnv.ts @@ -0,0 +1,22 @@ +export const validateEnv = ( + envSource: Record, + expectedVars: readonly T[], +): Record => { + const env = {} as Record; + const unset: string[] = []; + + expectedVars.forEach((envVar) => { + const value = envSource[envVar]?.trim(); + if (value === undefined || value.length === 0) { + unset.push(envVar); + return; + } + env[envVar] = value; + }); + + if (unset.length > 0) { + throw new Error(`Unset environment variables: ${unset.join(', ')}`); + } + + return env; +}; diff --git a/apps/availability/src/pages/_app.tsx b/apps/availability/src/pages/_app.tsx new file mode 100644 index 0000000..7114d8b --- /dev/null +++ b/apps/availability/src/pages/_app.tsx @@ -0,0 +1,22 @@ +import '../globals.css'; +import type { AppProps } from 'next/app'; +import Head from 'next/head'; +import dynamic from 'next/dynamic'; + +const App: React.FC = ({ Component, pageProps }: AppProps) => { + return ( + <> + + BlueDot Impact Availability + + + + ); +}; + +const AppWithNoSsr = dynamic( + () => Promise.resolve(App), + { ssr: false }, +); + +export default AppWithNoSsr; diff --git a/apps/availability/src/pages/api/public/get-form.ts b/apps/availability/src/pages/api/public/get-form.ts new file mode 100644 index 0000000..e45406e --- /dev/null +++ b/apps/availability/src/pages/api/public/get-form.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import createHttpError from 'http-errors'; +import db, { formConfigurationTable } from '../../../lib/api/db'; +import { apiRoute } from '../../../lib/api/apiRoute'; + +export type GetFormResponse = { + type: 'success', + title: string, + minimumLength: number, +}; + +export default apiRoute(async ( + req: NextApiRequest, + res: NextApiResponse, +) => { + const records = await db.scan(formConfigurationTable, { + filterByFormula: `{Slug} = "${req.query.slug}"`, + }); + + if (!records || records.length === 0) { + throw new createHttpError.NotFound('Form not found'); + } + + res.status(200).json({ + type: 'success', + title: records[0]?.Title || 'Unnamed form', + minimumLength: records[0]?.['Minimum length'] ?? 90, + }); +}, 'insecure_no_auth'); diff --git a/apps/availability/src/pages/api/public/submit.ts b/apps/availability/src/pages/api/public/submit.ts new file mode 100644 index 0000000..fd93b57 --- /dev/null +++ b/apps/availability/src/pages/api/public/submit.ts @@ -0,0 +1,57 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { parseIntervals } from 'weekly-availabilities'; +import axios from 'axios'; +import { apiRoute } from '../../../lib/api/apiRoute'; +import db, { formConfigurationTable } from '../../../lib/api/db'; + +export type SubmitRequest = { + email: string, + availability: string + timezone: string, + comments: string +}; + +const isValidAvailabilityExpression = (availability: string) => { + try { + parseIntervals(availability); + return true; + } catch (err) { + return false; + } +}; + +export default apiRoute(async ( + req: NextApiRequest, + res: NextApiResponse, +) => { + // TODO: schema validation + const data = req.body as SubmitRequest; + + if (!isValidAvailabilityExpression(req.body.availability)) { + res.status(400).send({ error: 'Invalid time availability expression' }); + return; + } + + const records = await db.scan(formConfigurationTable, { + filterByFormula: `{Slug} = "${req.query.slug}"`, + }); + + if (!records || records.length === 0) { + res.status(404).send({ type: 'error', message: 'Form not found' }); + return; + } + const record = records[0]!; + + const webhookResponse = await axios.post(record.Webhook, { + Comments: data.comments, + Email: data.email, + 'Time availability in UTC': data.availability, + Timezone: data.timezone, + }); + + if (webhookResponse.status !== 200) { + throw new Error(`Unexpected response from form webhook (status ${webhookResponse.status}) for form ${record.id}`); + } + + res.status(200).json({ type: 'success' }); +}, 'insecure_no_auth'); diff --git a/apps/availability/src/pages/api/status.test.ts b/apps/availability/src/pages/api/status.test.ts new file mode 100644 index 0000000..8c7d11e --- /dev/null +++ b/apps/availability/src/pages/api/status.test.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-underscore-dangle */ +import { NextApiRequest, NextApiResponse } from 'next'; +import type { Request, Response } from 'express'; +import { createMocks } from 'node-mocks-http'; +import { describe, expect, test } from 'vitest'; +import handle from './status'; + +describe('/api/status', () => { + test('returns an online status', async () => { + const { req, res } = createMocks< + NextApiRequest & Request, + NextApiResponse & Response + >({ method: 'GET' }); + + await handle(req, res); + + expect(res._getStatusCode()).toBe(200); + expect(JSON.parse(res._getData())).toEqual({ + status: 'Online', + }); + }); +}); diff --git a/apps/availability/src/pages/api/status.ts b/apps/availability/src/pages/api/status.ts new file mode 100644 index 0000000..dde0ea3 --- /dev/null +++ b/apps/availability/src/pages/api/status.ts @@ -0,0 +1,13 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { apiRoute } from '../../lib/api/apiRoute'; + +export type StatusResponse = { + status: string +}; + +export default apiRoute(async ( + req: NextApiRequest, + res: NextApiResponse, +) => { + res.status(200).json({ status: 'Online' }); +}, 'insecure_no_auth'); diff --git a/apps/availability/src/pages/form/[slug].tsx b/apps/availability/src/pages/form/[slug].tsx new file mode 100644 index 0000000..129fdda --- /dev/null +++ b/apps/availability/src/pages/form/[slug].tsx @@ -0,0 +1,551 @@ +import axios from 'axios'; +import { useRouter } from 'next/router'; +import { + useEffect, useRef, useState, useMemo, +} from 'react'; +import { + Controller, FormProvider, useForm, useFormContext, +} from 'react-hook-form'; +import Select from 'react-select'; +import { SpinnerIcon } from '../../components/SpinnerIcon'; +import { parseOffsetFromStringToMinutes, offsets } from '../../lib/date'; +import { pad, snapToRect } from '../../lib/util'; +import { SubmitRequest } from '../api/public/submit'; +import { GetFormResponse } from '../api/public/get-form'; + +// types +type Timepoint = { day: number; minutes: number }; +type Coord = { day: number; time: number }; + +const serializeCoord = ({ day, time }: Coord) => `${day},${time}`; + +// consts +const MINUTES_IN_UNIT = 30; +const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const browserTimezoneName = new Intl.DateTimeFormat().resolvedOptions().timeZone; + +// utils +const normalizeBlock = ({ + anchor: a, + cursor: c, +}: { + anchor: Coord; + cursor: Coord; +}) => { + return { + min: { day: Math.min(a.day, c.day), time: Math.min(a.time, c.time) }, + max: { day: Math.max(a.day, c.day), time: Math.max(a.time, c.time) }, + }; +}; + +const isWithin = ( + { min, max }: { min: Coord; max: Coord }, + { day, time }: Coord, +) => { + return ( + min.time <= time && max.time >= time && min.day <= day && max.day >= day + ); +}; + +const daySymbols = ['M', 'T', 'W', 'R', 'F', 'S', 'U']; +const stringifyTimepoint = ({ day, minutes }: Timepoint) => { + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + const dayName = daySymbols[(day + 7) % 7]; + return `${dayName}${pad(hour)}:${pad(minute)}`; +}; + +const shiftTimepoint = ({ day, minutes }: Timepoint, timezoneOrOffsetInMinutes: number | string) => { + const offsetInMinutes = typeof timezoneOrOffsetInMinutes === 'number' + ? timezoneOrOffsetInMinutes + : parseOffsetFromStringToMinutes(timezoneOrOffsetInMinutes); + + let newMinutes = minutes + offsetInMinutes; + + let newDay = day; + if (newMinutes < 0) { + newDay -= 1; + newMinutes += 1440; + } + if (newMinutes >= 1440) { + newDay += 1; + newMinutes -= 1440; + } + newDay = (newDay + 7) % 7; + + return { day: newDay, minutes: newMinutes }; +}; + +const timepointToMinutes = ({ day, minutes }: Timepoint) => { + return day * 1440 + minutes; +}; + +const coalesceTimeAv = ( + timeAv: { [serialisedCoord: string]: boolean }, + timezone: string, +) => { + const nIntervals = (24 * 60) / MINUTES_IN_UNIT; + + const timepoints = []; + for (let day = 0; day < days.length; day++) { + for (let time = 0; time < nIntervals; time++) { + const key = serializeCoord({ day, time }); + + if (timeAv[key]) { + const minutes = time * MINUTES_IN_UNIT; + timepoints.push(shiftTimepoint({ day, minutes }, timezone)); + } + } + } + timepoints.sort((a, b) => (a.day - b.day) * 1440 + a.minutes - b.minutes); + + const intervals = []; + // eslint-disable-next-line no-restricted-syntax + for (const timepoint of timepoints) { + const lastInterval = intervals[intervals.length - 1]; + + // If this is a separate interval, create a new interval + if ( + !lastInterval + || lastInterval.end.day !== timepoint.day + || lastInterval.end.minutes !== timepoint.minutes + ) { + intervals.push({ start: timepoint, end: timepoint }); + } + + intervals[intervals.length - 1]!.end = shiftTimepoint(timepoint, MINUTES_IN_UNIT); + } + + return intervals; +}; + +const serializeTimeAv = ( + timeAv: { [serialisedCoord: string]: boolean }, + timezone: string, +) => { + return coalesceTimeAv(timeAv, timezone) + .map(({ start, end }) => `${stringifyTimepoint(start)} ${stringifyTimepoint(end)}`) + .join(', '); +}; + +interface FormData { + email: string; + timezone: string; + timeAv: { [serialisedCoord: string]: boolean }; + comment: string; +} + +const TimeAvWidget: React.FC<{ show24: boolean }> = ({ show24 }) => { + const { watch, setValue } = useFormContext(); + + const startTime = show24 ? 0 : (8 * 60) / MINUTES_IN_UNIT; + const endTime = show24 + ? (24 * 60) / MINUTES_IN_UNIT + : (23 * 60) / MINUTES_IN_UNIT; + + const cellCoords: Coord[] = []; + const cellRefs: ({ ref: HTMLDivElement | null; coord: Coord } | null)[] = useMemo(() => [], []); + const times = []; + for (let i = startTime; i <= endTime; i++) { + times.push(i); + if (i !== endTime) { + for (let d = 0; d < days.length; d++) { + cellCoords.push({ day: d, time: i }); + cellRefs.push(null); + } + } + } + + const timeAv = watch('timeAv'); + + const timeToLabel = (time: number) => { + const minutes = time * MINUTES_IN_UNIT; + if (minutes < 0 || minutes > 1440) throw new Error(`Invalid time: ${time} (${minutes} mins)`); + const hours = Math.floor(minutes / 60); + const minutesRemaining = minutes - hours * 60; + return `${hours.toString().padStart(2, '0')}:${minutesRemaining.toString().padStart(2, '0')}`; + }; + + const [dragState, setDragState] = useState<{ + dragging: false | 'neg' | 'pos'; + anchor?: Coord; + cursor?: Coord; + }>({ dragging: false }); + + const dragStart = (cell: Coord) => { + setDragState({ + dragging: timeAv[serializeCoord(cell)] ? 'neg' : 'pos', + anchor: cell, + cursor: cell, + }); + }; + + const mainGrid = useRef(null); + + useEffect(() => { + const mouseMoveListener = (e: MouseEvent) => { + if (!dragState.dragging || !mainGrid.current) return; + + const mousepos = { x: e.clientX, y: e.clientY }; + const { x, y } = snapToRect(mainGrid.current.getBoundingClientRect(), mousepos); + + const cell = cellRefs.find((c) => { + if (!c?.ref) return false; + const { + top, bottom, left, right, + } = c.ref.getBoundingClientRect(); + + return (x >= left && x <= right && y >= top && y <= bottom); + }); + + if (cell) { + setDragState((prev) => ({ ...prev, cursor: cell.coord })); + } + }; + document.addEventListener('mousemove', mouseMoveListener); + + const mouseUpListener = () => { + if (!dragState.dragging || !dragState.anchor || !dragState.cursor) return; + const { min, max } = normalizeBlock({ + anchor: dragState.anchor, + cursor: dragState.cursor, + }); + + const targetVal = dragState.dragging === 'pos'; + for (let { day } = min; day <= max.day; day++) { + for (let { time } = min; time <= max.time; time++) { + setValue(`timeAv.${serializeCoord({ day, time })}`, targetVal); + } + } + setDragState({ dragging: false }); + }; + document.addEventListener('mouseup', mouseUpListener); + + return () => { + document.removeEventListener('mousemove', mouseMoveListener); + document.removeEventListener('mouseup', mouseUpListener); + }; + }, [cellRefs, dragState, mainGrid, setValue]); + + return ( +
+
+
+
+ {days.map((day) =>
{day}
)} +
+
+
+
+ {times.map((time, i) => i % 2 === 0 && ( +
+
{timeToLabel(time)}
+
+ ))} +
+
+
+ {cellCoords.map((coord, i) => { + const isBlocked = timeAv[serializeCoord(coord)]; + + const borderStyle = Math.floor(i / 7) % 2 === 0 + ? '[border-bottom-style:dotted]' + : 'border-solid'; + + const overlay = dragState.dragging + && dragState.anchor + && dragState.cursor + && isWithin( + normalizeBlock({ + anchor: dragState.anchor, + cursor: dragState.cursor, + }), + coord, + ); + + const overlayKind = overlay && dragState.dragging === 'pos' + ? 'bg-green-400' + : dragState.dragging === 'neg' && 'bg-purple-400'; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{ cellRefs[i] = { ref, coord }; }} + className={`relative h-4 ${ + isBlocked ? 'bg-green-400' : 'bg-red-50' + } border-gray-800 border-r border-b ${borderStyle}`} + onMouseDown={(e) => { + e.preventDefault(); + dragStart(coord); + }} + onTouchStart={(e) => { + e.preventDefault(); + dragStart(coord); + }} + > +
+ {overlay &&
} +
+ ); + })} +
+
+
+
+ ); +}; + +const TimeAv: React.FC = () => { + const [show24, setShow24] = useState(false); + const { setValue } = useFormContext(); + + return ( +
+ +
+
+ + +
+
+ ); +}; + +const TimeOffsetSelector: React.FC = () => { + const { control } = useFormContext(); + const options = offsets.map((s) => ({ value: s, label: s })); + + const [detected, setDetected] = useState(true); + + return ( +
+ + ( + + +
+ +
+
+

Click and drag to select your availability. Times are in your selected time offset - note that daylight savings may change your offset during the course, but your cohort will usually stay at the same UTC time.

+

+

+ +
+