├── .github ├── CODEOWNERS └── workflows │ ├── udd.yml │ └── ci.yml ├── .adr-dir ├── firestore.indexes.json ├── .firebaserc ├── doc ├── enable-api.png ├── screenshot.png ├── screenshot_consent_screen0.png ├── screenshot_consent_screen1.png └── architecture │ └── decisions │ ├── README.md │ ├── 0003-utilise-aleph-as-the-frontend-framework.md │ ├── 0001-record-architecture-decisions.md │ ├── 0002-utilise-supabase-for-persistance.md │ └── 0004-use-firestore-as-the-persistance-layer.md ├── .gitignore ├── dev.ts ├── main.ts ├── firebase.json ├── firestore.rules ├── storage.rules ├── utils ├── events.ts ├── api.ts ├── cx_test.ts ├── const.ts ├── jwt.ts ├── cx.ts ├── firestore_test_util.ts ├── db_test.ts ├── clipboard.ts ├── hooks.ts ├── firestore.ts ├── db.ts └── datetime_test.ts ├── assets ├── style.css └── gfm.css ├── .vscode └── settings.json ├── components ├── base │ ├── Badge.tsx │ ├── SlidingPanel.tsx │ ├── Container.tsx │ ├── Select.tsx │ ├── Status.tsx │ ├── Copyable.tsx │ ├── Input.tsx │ ├── Button.tsx │ ├── Modal.tsx │ ├── Notification.tsx │ ├── Dropdown.tsx │ └── Dialog.tsx ├── icons │ ├── Check.tsx │ ├── Exclamation.tsx │ ├── mod.ts │ ├── Spin.tsx │ ├── CaretLeft.tsx │ ├── CaretDown.tsx │ ├── CaretRight.tsx │ ├── Copy.tsx │ ├── Edit.tsx │ ├── Plus.tsx │ ├── Calendar.tsx │ ├── ExternalLink.tsx │ ├── Google.tsx │ ├── Logo.tsx │ ├── TrashBin.tsx │ ├── Close.tsx │ └── Deno.tsx ├── layout │ ├── Footer.tsx │ └── Header.tsx └── shared │ ├── TimeZoneSelect.tsx │ ├── EventTypeCard.tsx │ ├── EditEventTypeDialog.tsx │ └── AvailabilitySettings.tsx ├── tools ├── get_user_count.ts ├── test_ensure_access_token_freshness.ts └── test_get_user_availability.ts ├── .env.example ├── index.html ├── types.d.ts ├── routes ├── privacy.tsx ├── terms.tsx ├── _export.ts ├── mypage │ ├── index.tsx │ ├── settings.tsx │ └── onboarding.tsx ├── api │ ├── authorize.ts │ └── user.ts ├── _app.tsx ├── $user │ └── index.tsx └── index.tsx ├── deno.json ├── LICENSE ├── import_map.json ├── server.tsx ├── README.md ├── PRIVACY.md ├── TERMS.md └── test └── api_user_test.ts /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @kt3k 2 | -------------------------------------------------------------------------------- /.adr-dir: -------------------------------------------------------------------------------- 1 | doc/architecture/decisions 2 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "meet-me-aee8c" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /doc/enable-api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denoland/meet-me/HEAD/doc/enable-api.png -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denoland/meet-me/HEAD/doc/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .keys 4 | dist 5 | # firebase emulator logs 6 | *-debug.log 7 | deno.lock 8 | -------------------------------------------------------------------------------- /doc/screenshot_consent_screen0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denoland/meet-me/HEAD/doc/screenshot_consent_screen0.png -------------------------------------------------------------------------------- /doc/screenshot_consent_screen1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denoland/meet-me/HEAD/doc/screenshot_consent_screen1.png -------------------------------------------------------------------------------- /dev.ts: -------------------------------------------------------------------------------- 1 | import dev from "aleph/dev"; 2 | 3 | dev({ 4 | baseUrl: import.meta.url, 5 | generateExportTs: true, 6 | }); 7 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | // The frontend entrypoint 3 | 4 | import { bootstrap } from "aleph/react-client"; 5 | 6 | bootstrap(); 7 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "storage": { 7 | "rules": "storage.rules" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read, write: if true; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service firebase.storage { 3 | match /b/{bucket}/o { 4 | match /{allPaths=**} { 5 | allow read, write: if request.auth!=null; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /utils/events.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import mitt from "https://esm.sh/mitt@3.0.0"; 4 | 5 | // shared event emitter 6 | export default mitt>>(); 7 | -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | --color-canvas-default: transparent; 3 | } 4 | 5 | .markdown-body p, .markdown-body h2, .markdown-body h1 { 6 | color: #eeeeee; 7 | } 8 | 9 | .markdown-body ul li { 10 | list-style-type: disc; 11 | } 12 | -------------------------------------------------------------------------------- /utils/api.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | export function badRequest(message = "") { 4 | return Response.json({ message }, { status: 400 }); 5 | } 6 | 7 | export function ok(obj = {}) { 8 | return Response.json(obj); 9 | } 10 | -------------------------------------------------------------------------------- /utils/cx_test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { assertEquals } from "std/testing/asserts.ts"; 4 | import cx from "./cx.ts"; 5 | 6 | Deno.test("cx", () => { 7 | assertEquals(cx("foo", true && "bar", false && "baz"), "foo bar"); 8 | assertEquals(cx("foo", { bar: true, baz: false }), "foo bar"); 9 | assertEquals(cx("foo", ["bar", 123]), "foo bar"); 10 | }); 11 | -------------------------------------------------------------------------------- /doc/architecture/decisions/README.md: -------------------------------------------------------------------------------- 1 | # Architecture Decision Records 2 | 3 | - [1. Record architecture decisions](0001-record-architecture-decisions.md) 4 | - [2. Utilise supabase for persistance](0002-utilise-supabase-for-persistance.md) 5 | - [3. Utilise aleph as the frontend framework](0003-utilise-aleph-as-the-frontend-framework.md) 6 | - [4. Use firestore as the persistance layer.](0004-use-firestore-as-the-persistance-layer.md) 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true, 5 | "deno.config": "./deno.json", 6 | "deno.importMap": "./import_map.json", 7 | "[javascript][json][jsonc][typescript][typescriptreact]": { 8 | "editor.formatOnSave": true, 9 | "editor.defaultFormatter": "denoland.vscode-deno", 10 | "editor.insertSpaces": true, 11 | "editor.tabSize": 2 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /components/base/Badge.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { ReactNode } from "react"; 5 | 6 | export default function Badge( 7 | { children }: { children: ReactNode | undefined }, 8 | ) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /utils/const.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | export const TOKEN_ENDPOINT = Deno.env.get("TOKEN_ENDPOINT") ?? 4 | "https://oauth2.googleapis.com/token"; 5 | export const CALENDAR_FREE_BUSY_API = Deno.env.get("CALENDAR_FREE_BUSY_API") ?? 6 | "https://www.googleapis.com/calendar/v3/freeBusy"; 7 | export const CALENDAR_EVENTS_API = Deno.env.get("CALENDAR_EVENTS_API") ?? 8 | "https://www.googleapis.com/calendar/v3/calendars/:calendarId/events"; 9 | -------------------------------------------------------------------------------- /utils/jwt.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | /** Gets the payload object from jwt token */ 4 | export function parsePayload(jwt: string) { 5 | const base64 = jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); 6 | const json = decodeURIComponent( 7 | atob(base64).split("").map( 8 | (c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2), 9 | ).join(""), 10 | ); 11 | const payload = JSON.parse(json); 12 | return payload; 13 | } 14 | -------------------------------------------------------------------------------- /doc/architecture/decisions/0003-utilise-aleph-as-the-frontend-framework.md: -------------------------------------------------------------------------------- 1 | # 3. Utilise aleph as the frontend framework 2 | 3 | Date: 2022-05-02 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | The issue motivating this decision, and any context that influences or 12 | constrains the decision. 13 | 14 | ## Decision 15 | 16 | Utilise aleph as the frontend framework. 17 | 18 | ## Consequences 19 | 20 | What becomes easier or more difficult to do and any risks introduced by the 21 | change that will need to be mitigated. 22 | -------------------------------------------------------------------------------- /tools/get_user_count.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | // Returns the count of the all users. 4 | // This script loads the entire users in memory and very slow. 5 | import "https://deno.land/std@0.150.0/dotenv/load.ts"; 6 | import { 7 | collection, 8 | getDocs, 9 | initFirestore, 10 | query, 11 | } from "../utils/firestore.ts"; 12 | 13 | const firestore = initFirestore(); 14 | 15 | const snapshot = await getDocs(query(collection(firestore, "users"))); 16 | 17 | console.log(snapshot.size); 18 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Google API Client ID & Secret 2 | CLIENT_ID=012345678901-abcdefghijklmnopqrstuvwxyz012345.apps.googleusercontent.com 3 | CLIENT_SECRET=ABCDEF-0123456789_abc_ABCDEFGHIJKLM 4 | REDIRECT_URI=http://localhost:3000/api/authorize 5 | 6 | # firebase app info 7 | FIREBASE_API_KEY=abcdefg12345 8 | FIREBASE_AUTH_DOMAIN=example.firebaseapp.com 9 | FIREBASE_PROJECT_ID=example 10 | FIREBASE_STORAGE_BUCKET=example.appspot.com 11 | FIREBASE_MESSING_SENDER_ID=1234567890 12 | FIREBASE_APP_ID=1:1234567890:web:a2bc3d 13 | FIREBASE_MEASUREMENT_ID=G-123456 14 | -------------------------------------------------------------------------------- /doc/architecture/decisions/0001-record-architecture-decisions.md: -------------------------------------------------------------------------------- 1 | # 1. Record architecture decisions 2 | 3 | Date: 2022-05-02 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | We need to record the architectural decisions made on this project. 12 | 13 | ## Decision 14 | 15 | We will use Architecture Decision Records, as 16 | [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 17 | 18 | ## Consequences 19 | 20 | See Michael Nygard's article, linked above. For a lightweight ADR toolset, see 21 | Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools). 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Meet Me 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/udd.yml: -------------------------------------------------------------------------------- 1 | name: udd 2 | on: 3 | schedule: 4 | - cron: "0 0 1 * *" # Monthly 5 | workflow_dispatch: 6 | jobs: 7 | update: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: denoland/setup-deno@v1 12 | - name: Update Dependencies 13 | run: | 14 | deno run -A https://deno.land/x/udd@0.8.1/main.ts ./import_map.json 15 | - name: Create Pull Request 16 | uses: peter-evans/create-pull-request@v4 17 | with: 18 | title: "chore(deps): Update dependencies" 19 | commit-message: "chore(deps): Update dependencies" 20 | delete-branch: true 21 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | declare const google: GoogleApi; 4 | declare const party: Party; 5 | 6 | interface IdConfiguration { 7 | client_id: string; 8 | callback: (res: { credential: string }) => void; 9 | } 10 | 11 | interface GoogleApi { 12 | accounts: { 13 | id: { 14 | initialize(conf: IdConfiguration): void; 15 | renderButton(el: Element, opts: unknown): void; 16 | }; 17 | // deno-lint-ignore no-explicit-any 18 | oauth2: any; 19 | }; 20 | } 21 | 22 | interface Party { 23 | confetti(...args: unknown[]): void; 24 | variation: { 25 | range(x: number, y: number): unknown; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /components/icons/Check.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | type IconProps = { 5 | size?: number; 6 | className?: string; 7 | }; 8 | 9 | export default function Icon({ size = 16, className }: IconProps) { 10 | return ( 11 | 19 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /routes/privacy.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { useData } from "aleph/react"; 4 | import * as gfm from "gfm/mod.ts"; 5 | 6 | let html: string; 7 | export const data = { 8 | async get(_: Request, _ctx: Context) { 9 | if (typeof html === "undefined") { 10 | const text = await Deno.readTextFile("./PRIVACY.md"); 11 | html = gfm.render(text); 12 | } 13 | return Response.json({ html }); 14 | }, 15 | }; 16 | 17 | export default function Privacy() { 18 | const { data } = useData<{ html: string }>(); 19 | return ( 20 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /routes/terms.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { useData } from "aleph/react"; 4 | import * as gfm from "gfm/mod.ts"; 5 | 6 | let html: string; 7 | 8 | export const data = { 9 | async get(_: Request, _ctx: Context) { 10 | if (typeof html === "undefined") { 11 | const text = await Deno.readTextFile("./TERMS.md"); 12 | html = gfm.render(text); 13 | } 14 | return Response.json({ html }); 15 | }, 16 | }; 17 | 18 | export default function Terms() { 19 | const { data } = useData<{ html: string }>(); 20 | return ( 21 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /utils/cx.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | export default function cx(...classNames: unknown[]): string { 4 | const finalClassNames: string[] = []; 5 | classNames.forEach((name) => { 6 | if (typeof name === "string" && name !== "") { 7 | finalClassNames.push(name); 8 | } 9 | if (typeof name === "object" && name !== null) { 10 | if (Array.isArray(name)) { 11 | finalClassNames.push(cx(...name)); 12 | } else { 13 | Object.entries(name).forEach(([key, value]) => { 14 | if (typeof key === "string" && value) { 15 | finalClassNames.push(key); 16 | } 17 | }); 18 | } 19 | } 20 | }); 21 | return finalClassNames.join(" "); 22 | } 23 | -------------------------------------------------------------------------------- /components/base/SlidingPanel.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { PropsWithChildren } from "react"; 5 | import cx from "utils/cx.ts"; 6 | 7 | export type PanelState = "left" | "center" | "right"; 8 | 9 | export default function SlidingPanel( 10 | { state, children, className }: PropsWithChildren< 11 | { state: PanelState; className?: string } 12 | >, 13 | ) { 14 | return ( 15 |
22 | {children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | ci: 12 | name: CI 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Clone repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Install Deno 20 | uses: denoland/setup-deno@v1 21 | with: 22 | deno-version: v1.x 23 | 24 | - name: fmt check 25 | run: deno fmt --check 26 | 27 | - name: lint 28 | run: deno lint 29 | 30 | - name: Install firebase-tools 31 | run: npx firebase-tools@10.9.2 -V 32 | 33 | - name: Test 34 | run: npx start-server-and-test 'deno task firestore-emulator' 4000 'deno task test' 35 | -------------------------------------------------------------------------------- /components/icons/Exclamation.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | type IconProps = { 5 | size?: number; 6 | className?: string; 7 | }; 8 | 9 | export default function Icon({ size = 16, className }: IconProps) { 10 | return ( 11 | 19 | 20 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/base/Container.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import type { 5 | HTMLAttributes, 6 | MutableRefObject, 7 | PropsWithChildren, 8 | } from "react"; 9 | import cx from "utils/cx.ts"; 10 | 11 | type ContainerProps = PropsWithChildren< 12 | & { 13 | small?: boolean; 14 | innerRef?: MutableRefObject; 15 | } 16 | & HTMLAttributes 17 | >; 18 | 19 | export function ShadowBox({ className, children, ...rest }: ContainerProps) { 20 | return ( 21 |
28 | {children} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/base/Select.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { SelectHTMLAttributes } from "react"; 5 | import cx from "utils/cx.ts"; 6 | 7 | type Props = 8 | & { onChange?(value: string): void } 9 | & Omit, "onChange">; 10 | 11 | export default function Select(props: Props) { 12 | const { children, onChange, className, ...rest } = props; 13 | return ( 14 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /utils/firestore_test_util.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { firestore } from "./firestore.ts"; 4 | 5 | export async function resetEmulatorDocuments() { 6 | const resp = await fetch( 7 | `http://localhost:8080/emulator/v1/projects/${firestore.app.options.projectId}/databases/(default)/documents`, 8 | { method: "DELETE" }, 9 | ); 10 | await resp.arrayBuffer(); 11 | } 12 | 13 | export function setTestFirebaseEnvVars() { 14 | Deno.env.set("FIREBASE_API_KEY", "abcdefg12345"); 15 | Deno.env.set("FIREBASE_AUTH_DOMAIN", "example.firebaseapp.com"); 16 | Deno.env.set("FIREBASE_PROJECT_ID", "example"); 17 | Deno.env.set("FIREBASE_STORAGE_BUCKET", "example.appspot.com"); 18 | Deno.env.set("FIREBASE_MESSING_SENDER_ID", "1234567890"); 19 | Deno.env.set("FIREBASE_APP_ID", "1:1234567890:web:a2bc3d"); 20 | Deno.env.set("FIREBASE_MEASUREMENT_ID", "G-123456"); 21 | } 22 | -------------------------------------------------------------------------------- /routes/_export.ts: -------------------------------------------------------------------------------- 1 | // Imports router modules for serverless env that doesn't support the dynamic import. 2 | // This module will be updated automaticlly in develoment mode, do NOT edit it manually. 3 | 4 | import * as $0 from "./_app.tsx"; 5 | import * as $1 from "./index.tsx"; 6 | import * as $2 from "./terms.tsx"; 7 | import * as $3 from "./privacy.tsx"; 8 | import * as $4 from "./mypage/settings.tsx"; 9 | import * as $5 from "./mypage/index.tsx"; 10 | import * as $6 from "./mypage/onboarding.tsx"; 11 | import * as $7 from "./api/authorize.ts"; 12 | import * as $8 from "./api/user.ts"; 13 | import * as $9 from "./$user/index.tsx"; 14 | import * as $10 from "./$user/$event_type.tsx"; 15 | 16 | export default { 17 | "/_app": $0, 18 | "/": $1, 19 | "/terms": $2, 20 | "/privacy": $3, 21 | "/mypage/settings": $4, 22 | "/mypage/index": $5, 23 | "/mypage/onboarding": $6, 24 | "/api/authorize": $7, 25 | "/api/user": $8, 26 | "/:user/index": $9, 27 | "/:user/:event_type": $10, 28 | }; 29 | -------------------------------------------------------------------------------- /components/icons/mod.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import Calendar from "./Calendar.tsx"; 4 | import CaretDown from "./CaretDown.tsx"; 5 | import CaretLeft from "./CaretLeft.tsx"; 6 | import CaretRight from "./CaretRight.tsx"; 7 | import Check from "./Check.tsx"; 8 | import Close from "./Close.tsx"; 9 | import Copy from "./Copy.tsx"; 10 | import Deno from "./Deno.tsx"; 11 | import Edit from "./Edit.tsx"; 12 | import Exclamation from "./Exclamation.tsx"; 13 | import ExternalLink from "./ExternalLink.tsx"; 14 | import Google from "./Google.tsx"; 15 | import Logo from "./Logo.tsx"; 16 | import Plus from "./Plus.tsx"; 17 | import Spin from "./Spin.tsx"; 18 | import TrashBin from "./TrashBin.tsx"; 19 | 20 | export default { 21 | Calendar, 22 | CaretDown, 23 | CaretLeft, 24 | CaretRight, 25 | Check, 26 | Close, 27 | Copy, 28 | Deno, 29 | Edit, 30 | Exclamation, 31 | ExternalLink, 32 | Google, 33 | Logo, 34 | Plus, 35 | Spin, 36 | TrashBin, 37 | }; 38 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "dev": "deno run -A -q dev.ts", 4 | "start": "deno run -A server.tsx", 5 | "firestore-emulator": "npx firebase-tools@11.16.0 emulators:start", 6 | "test": "deno test -A --unstable" 7 | }, 8 | "fmt": { 9 | "files": { 10 | "exclude": [ 11 | "dist", 12 | "vendor" 13 | ] 14 | } 15 | }, 16 | "lint": { 17 | "rules": { 18 | "exclude": [ 19 | "import-prefix-missing" 20 | ] 21 | }, 22 | "files": { 23 | "exclude": [ 24 | "dist", 25 | "vendor" 26 | ] 27 | } 28 | }, 29 | "compilerOptions": { 30 | "lib": [ 31 | "dom", 32 | "dom.iterable", 33 | "dom.asynciterable", 34 | "deno.ns", 35 | "deno.unstable" 36 | ], 37 | "types": [ 38 | "https://deno.land/x/aleph@1.0.0-beta.21/types.d.ts", 39 | "./types.d.ts" 40 | ], 41 | "jsx": "react-jsx", 42 | "jsxImportSource": "https://esm.sh/react@18.2.0" 43 | }, 44 | "importMap": "./import_map.json" 45 | } 46 | -------------------------------------------------------------------------------- /components/icons/Spin.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import cx from "utils/cx.ts"; 5 | 6 | type IconProps = { 7 | size?: number; 8 | className?: string; 9 | }; 10 | 11 | export default function Icon({ size = 16, className }: IconProps) { 12 | return ( 13 | 21 | 29 | 30 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /components/icons/CaretLeft.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | export default function CaretLeft( 5 | { className, size = 17 }: { className?: string; size?: number }, 6 | ) { 7 | return ( 8 | 16 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/icons/CaretDown.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | export default function CaretDown( 5 | { className, size = 17 }: { className?: string; size?: number }, 6 | ) { 7 | return ( 8 | 16 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/icons/CaretRight.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | export default function CaretRight( 5 | { className, size = 17 }: { className?: string; size?: number }, 6 | ) { 7 | return ( 8 | 16 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2022 the Deno authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /components/icons/Copy.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | type IconProps = { 5 | size?: number; 6 | className?: string; 7 | }; 8 | 9 | export default function Icon({ size = 16, className }: IconProps) { 10 | return ( 11 | 19 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /routes/mypage/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { useEffect } from "react"; 5 | import { useForwardProps, useRouter } from "aleph/react"; 6 | import { type UserForClient as User } from "utils/db.ts"; 7 | import EventTypeCard, { NewEventTypeCard } from "shared/EventTypeCard.tsx"; 8 | 9 | export default function MyPage() { 10 | const { user, reloadUser } = useForwardProps< 11 | { user: User; reloadUser: () => Promise } 12 | >(); 13 | 14 | const { redirect } = useRouter(); 15 | 16 | useEffect(() => { 17 | if (!user) { 18 | redirect("/"); 19 | } 20 | }); 21 | 22 | if (!user) { 23 | return null; 24 | } 25 | 26 | return ( 27 |
28 |
29 | {user.eventTypes!.map((et) => ( 30 | 36 | ))} 37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "components/": "./components/", 4 | "base/": "./components/base/", 5 | "layout/": "./components/layout/", 6 | "shared/": "./components/shared/", 7 | "icons": "./components/icons/mod.ts", 8 | "icons/": "./components/icons/", 9 | "utils/": "./utils/", 10 | "std/": "https://deno.land/std@0.170.0/", 11 | "@unocss/core": "https://esm.sh/@unocss/core@0.48.0", 12 | "@unocss/preset-uno": "https://esm.sh/@unocss/preset-uno@0.48.0", 13 | "aleph/": "https://deno.land/x/aleph@1.0.0-beta.21/", 14 | "aleph/server": "https://deno.land/x/aleph@1.0.0-beta.21/server/mod.ts", 15 | "aleph/dev": "https://deno.land/x/aleph@1.0.0-beta.21/server/dev.ts", 16 | "aleph/react": "https://deno.land/x/aleph@1.0.0-beta.21/runtime/react/mod.ts", 17 | "aleph/react-client": "https://deno.land/x/aleph@1.0.0-beta.21/runtime/react/client.ts", 18 | "aleph/react-server": "https://deno.land/x/aleph@1.0.0-beta.21/runtime/react/server.ts", 19 | "react": "https://esm.sh/react@18.2.0", 20 | "react-dom": "https://esm.sh/react-dom@18.2.0", 21 | "react-dom/": "https://esm.sh/react-dom@18.2.0/", 22 | "gfm/": "https://deno.land/x/gfm@0.1.28/" 23 | }, 24 | "scopes": {} 25 | } 26 | -------------------------------------------------------------------------------- /utils/db_test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { assertEquals, assertRejects } from "std/testing/asserts.ts"; 4 | import { createUserByEmail, getUserById } from "./db.ts"; 5 | import { initFirestore, useEmulator } from "./firestore.ts"; 6 | import { 7 | resetEmulatorDocuments, 8 | setTestFirebaseEnvVars, 9 | } from "./firestore_test_util.ts"; 10 | 11 | Deno.test( 12 | "test db using firestore local emulator", 13 | { sanitizeOps: false, sanitizeResources: false }, 14 | async (t) => { 15 | setTestFirebaseEnvVars(); 16 | useEmulator(initFirestore()); 17 | // Resets the emulators data 18 | await resetEmulatorDocuments(); 19 | await t.step({ 20 | name: "createUserByEmail", 21 | async fn() { 22 | const email = "john@example.com"; 23 | const { id } = await createUserByEmail(email); 24 | const user = await getUserById(id); 25 | assertEquals(user?.email, email); 26 | 27 | await assertRejects( 28 | () => createUserByEmail(email), 29 | Error, 30 | "The email is already userd by another user", 31 | ); 32 | }, 33 | sanitizeOps: false, 34 | sanitizeResources: false, 35 | }); 36 | }, 37 | ); 38 | -------------------------------------------------------------------------------- /server.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import presetUno from "@unocss/preset-uno"; 4 | import { serve } from "aleph/react-server"; 5 | import { initFirestore } from "utils/firestore.ts"; 6 | import "std/dotenv/load.ts"; 7 | 8 | // pre-import route modules for serverless env that doesn't support the dynamic imports. 9 | import routes from "./routes/_export.ts"; 10 | 11 | initFirestore(); 12 | 13 | const Signout: Middleware = { 14 | fetch(req, ctx) { 15 | const { pathname } = new URL(req.url); 16 | if (pathname === "/signout") { 17 | return new Response("", { 18 | status: 303, 19 | headers: { 20 | location: "/", 21 | "Set-Cookie": "token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT", 22 | }, 23 | }); 24 | } 25 | return ctx.next(); 26 | }, 27 | }; 28 | 29 | serve({ 30 | port: 3000, 31 | router: { 32 | routes, 33 | glob: "./routes/**/*.{ts,tsx}", 34 | }, 35 | unocss: { 36 | presets: [presetUno()], 37 | theme: { 38 | colors: { 39 | "default": "#222222", 40 | "primary": "#00AC47", 41 | "fresh": "#00AC47", 42 | "danger": "#E90807", 43 | }, 44 | }, 45 | }, 46 | middlewares: [Signout], 47 | ssr: true, 48 | }); 49 | -------------------------------------------------------------------------------- /components/layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import icons from "icons"; 5 | 6 | const links = [ 7 | ["https://github.com/denoland/meet-me", "Source"], 8 | [ 9 | "https://www.figma.com/file/P0XsTDIeiwNhm8jFS03gwz/Deno-Showcases-Mockup?node-id=0%3A1", 10 | "Figma", 11 | ], 12 | ["https://github.com/denoland/meet-me/issues", "Issues"], 13 | ["/terms", "Terms"], 14 | ["/privacy", "Privacy"], 15 | ]; 16 | 17 | export function Footer() { 18 | return ( 19 |
20 | 21 | Powered by Deno 22 | 23 |
24 | {links.map(([href, text]) => ( 25 | 30 | {text} 31 | 32 | ))} 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /doc/architecture/decisions/0002-utilise-supabase-for-persistance.md: -------------------------------------------------------------------------------- 1 | # 2. Utilise supabase for persistance 2 | 3 | Date: 2022-05-02 4 | 5 | ## Status 6 | 7 | Superceded by 8 | [4. Use firestore as the persistance layer.](0004-use-firestore-as-the-persistance-layer.md) 9 | 10 | ## Context 11 | 12 | We need to persist information about users, the configuration for the user and 13 | other information. 14 | 15 | Because the nature of data is heavy user and role based, it makes sense to use a 16 | solution the provides clean APIs for such integration, instead of having to add 17 | a layer on top to help ensure that we don't "leak" data from user to user. 18 | 19 | An application framework like Firebase is well suited for the task. supabase is 20 | an open source alternative to Firebase which provides similar persistance 21 | functionality built on top of a Postgres database, but with abstractions around 22 | user data. supabase is also a start-up leveraging Deno Deploy as a compute 23 | layer. 24 | 25 | ## Decision 26 | 27 | Utilize supabase as the persistance layer. 28 | 29 | ## Consequences 30 | 31 | Using emergent open source technology always comes with its risks. There is no 32 | clear migration from supabase to other platforms if it appears to be 33 | insufficient, but we are likely to have good conversations about any product 34 | features or issues. 35 | -------------------------------------------------------------------------------- /components/icons/Edit.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | type IconProps = { 5 | size?: number; 6 | className?: string; 7 | }; 8 | 9 | export default function Icon({ size = 16, className }: IconProps) { 10 | return ( 11 | 19 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/icons/Plus.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | type IconProps = { 4 | size?: number; 5 | className?: string; 6 | }; 7 | 8 | export default function Icon({ size = 16, className }: IconProps) { 9 | return ( 10 | 18 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /doc/architecture/decisions/0004-use-firestore-as-the-persistance-layer.md: -------------------------------------------------------------------------------- 1 | # 4. Use firestore as the persistance layer. 2 | 3 | Date: 2022-05-10 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | Supercedes 10 | [2. Utilise supabase for persistance](0002-utilise-supabase-for-persistance.md) 11 | 12 | ## Context 13 | 14 | One of the larger objectives of the showcase application is to have a diversity 15 | of technologies integrated. The `showcase_chat` team has chosen supabase as the 16 | persistance layer. The team reconsidered if supbase would be providing any 17 | specific unique functionality and to re-evaluate other suitable technologies. 18 | 19 | We considered: 20 | 21 | - spanner 22 | - Edge.db 23 | - Firebase/Firestore 24 | - Prisma 25 | 26 | Considering the dependency on other Google APIs (Calendar) and the user centric 27 | nature of the application, Firebase/Firestore appears to be the best solution 28 | and demonstrates integration of a popular platform for applications. 29 | 30 | ## Decision 31 | 32 | We will use Firebase/Firestore instead of Supabase. 33 | 34 | ## Consequences 35 | 36 | Firebase/Firestore are well documented APIs, though their integration into Deno 37 | Deploy is somewhat experimental. There maybe unseen challenges. 38 | 39 | Management of the solution though should be easier, as Firebase is integrated 40 | into the suite of GCP services. 41 | -------------------------------------------------------------------------------- /components/icons/Calendar.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | export default function CaretDown( 5 | { className, size = 17 }: { className?: string; size?: number }, 6 | ) { 7 | return ( 8 | 16 | 20 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /components/shared/TimeZoneSelect.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import Dropdown from "base/Dropdown.tsx"; 5 | import { TimeZone, timeZones } from "utils/datetime.ts"; 6 | 7 | type Props = { 8 | timeZone: string; 9 | onTimeZoneSelect: (timeZone: TimeZone) => void; 10 | }; 11 | 12 | export default function TimeZoneSelect({ timeZone, onTimeZoneSelect }: Props) { 13 | return ( 14 |

15 | Timezone:{" "} 16 | ( 19 |

20 |
    21 | {timeZones.map((timeZone) => ( 22 |
  • { 26 | onTimeZoneSelect(timeZone); 27 | }} 28 | > 29 | {timeZone} 30 |
  • 31 | ))} 32 |
33 |
34 | )} 35 | > 36 | 37 | {timeZone} 38 | 39 | 40 |

41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/icons/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | type IconProps = { 5 | className?: string; 6 | }; 7 | 8 | export default function Icon({ className }: IconProps) { 9 | return ( 10 | 18 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/base/Status.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import cx from "utils/cx.ts"; 5 | import icons from "../icons/mod.ts"; 6 | 7 | type StatusProps = { 8 | iconSize?: number; 9 | className?: string; 10 | }; 11 | 12 | const sharedClassName = 13 | "w-5 h-5 inline-flex items-center justify-center rounded-full flex-shrink-0"; 14 | 15 | export function Ok({ className, iconSize = 10 }: StatusProps) { 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | export function Info({ className, iconSize = 10 }: StatusProps) { 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export function Warn({ className, iconSize = 10 }: StatusProps) { 32 | return ( 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export function Danger({ className, iconSize = 10 }: StatusProps) { 40 | return ( 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | /** 4 | * Copy a string to the clipboard, with support for ios safari. 5 | * Adapted from https://stackoverflow.com/a/53951634/938822. 6 | * @param text The text to copy to the cliboard 7 | * @returns `true` if copying was successful, `false` if not. 8 | */ 9 | export async function copyToClipboard(text: string) { 10 | if (navigator.clipboard) { 11 | try { 12 | await navigator.clipboard.writeText(text); 13 | return true; 14 | } catch (_err) { 15 | return false; 16 | } 17 | } 18 | 19 | const textarea = document.createElement("textarea"); 20 | try { 21 | textarea.setAttribute("readonly", "readonly"); 22 | textarea.setAttribute("contenteditable", "contenteditable"); 23 | textarea.value = text; 24 | textarea.style.position = "fixed"; 25 | 26 | document.body.appendChild(textarea); 27 | 28 | textarea.focus(); 29 | textarea.select(); 30 | 31 | const range = document.createRange(); 32 | range.selectNodeContents(textarea); 33 | 34 | const selection = window.getSelection(); 35 | if (!selection) { 36 | return false; 37 | } 38 | selection!.removeAllRanges(); 39 | selection!.addRange(range); 40 | 41 | textarea.setSelectionRange(0, textarea.value.length); 42 | 43 | return document.execCommand("copy"); 44 | } catch (err) { 45 | console.error(err); 46 | return false; 47 | } finally { 48 | document.body.removeChild(textarea); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /utils/hooks.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { useEffect } from "react"; 4 | 5 | // better ensure the `cb` is wrapered by the `useCallback` hook 6 | export function onKeyDown(matches: KBEventFilter, cb: () => void) { 7 | onKeyPress(matches, (down) => down && cb()); 8 | } 9 | 10 | // better ensure the `cb` is wrapered by the `useCallback` hook 11 | export function onKeyUp(matches: KBEventFilter, cb: () => void) { 12 | onKeyPress(matches, (down) => !down && cb()); 13 | } 14 | 15 | type KBEventFilter = (e: KeyboardEvent) => boolean; 16 | type onPressCallback = (down: boolean) => void; 17 | 18 | // better ensure the `onPress` is wrapered by the `useCallback` hook 19 | export function onKeyPress(matches: KBEventFilter, onPress: onPressCallback) { 20 | useEffect(() => { 21 | const downHandler = (e: KeyboardEvent) => { 22 | if (matches(e)) { 23 | e.preventDefault(); 24 | onPress(true); 25 | } 26 | }; 27 | const upHandler = (e: KeyboardEvent) => { 28 | if (matches(e)) { 29 | e.preventDefault(); 30 | onPress(false); 31 | } 32 | }; 33 | 34 | // Add event listeners 35 | globalThis.window.addEventListener("keydown", downHandler); 36 | globalThis.window.addEventListener("keyup", upHandler); 37 | 38 | // Remove event listeners on cleanup 39 | return () => { 40 | globalThis.window.removeEventListener("keydown", downHandler); 41 | globalThis.window.removeEventListener("keyup", upHandler); 42 | }; 43 | }, [onPress]); 44 | } 45 | -------------------------------------------------------------------------------- /components/icons/Google.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | export default function Google() { 5 | return ( 6 | 13 | 17 | 21 | 25 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/icons/Logo.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | export default function Logo() { 5 | return ( 6 | 13 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /tools/test_ensure_access_token_freshness.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { parse } from "https://deno.land/std@0.143.0/flags/mod.ts"; 4 | import { ensureAccessTokenIsFreshEnough, User } from "../utils/db.ts"; 5 | import { TOKEN_ENDPOINT } from "../utils/const.ts"; 6 | import "https://deno.land/std@0.143.0/dotenv/load.ts"; 7 | 8 | function usage() { 9 | console.log( 10 | `Usage: ./tools/test_ensure_access_token_freshness.ts --access-token --refresh-token --expires 11 | Note: This tool checks the behavior of ensureAccessTokenIsFreshEnough function against actual google OAuth token endpoint`, 12 | ); 13 | } 14 | 15 | const args = parse(Deno.args, { 16 | string: ["access-token", "refresh-token", "expires"], 17 | boolean: ["h"], 18 | }); 19 | const accessToken = args["access-token"]; 20 | const refreshToken = args["refresh-token"]; 21 | const expires = args["expires"]; 22 | if (args.h) { 23 | usage(); 24 | Deno.exit(0); 25 | } 26 | if (!accessToken || !refreshToken || !expires) { 27 | usage(); 28 | Deno.exit(1); 29 | } 30 | const user: User = { 31 | id: crypto.randomUUID(), 32 | email: "foo@example.com", 33 | name: "test user", 34 | givenName: "test", 35 | familyName: "user", 36 | picture: "avatar.png", 37 | slug: "foo", 38 | googleRefreshToken: args["refresh-token"], 39 | googleAccessToken: args["access-token"], 40 | googleAccessTokenExpires: new Date(args["expires"]), 41 | timeZone: "Europe/London", 42 | availabilities: [], 43 | eventTypes: [], 44 | }; 45 | console.log("User before ensuring", user); 46 | await ensureAccessTokenIsFreshEnough(user, TOKEN_ENDPOINT); 47 | console.log("User after ensuring", user); 48 | -------------------------------------------------------------------------------- /components/icons/TrashBin.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Deno Land Inc. All Rights Reserved. Proprietary and confidential. 2 | 3 | type IconProps = { 4 | size?: number; 5 | className?: string; 6 | }; 7 | 8 | export default function Icon({ size = 16, className }: IconProps) { 9 | return ( 10 | 18 | 25 | 32 | 39 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /routes/api/authorize.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { parsePayload } from "utils/jwt.ts"; 4 | import { 5 | createNewTokenForUser, 6 | getOrCreateUserByEmail, 7 | saveUser, 8 | } from "utils/db.ts"; 9 | 10 | export const GET = async (req: Request) => { 11 | const params = new URLSearchParams(new URL(req.url).search); 12 | const form = new URLSearchParams(); 13 | form.append("code", params.get("code")!); 14 | form.append("client_id", Deno.env.get("CLIENT_ID")!); 15 | form.append("client_secret", Deno.env.get("CLIENT_SECRET")!); 16 | form.append("grant_type", "authorization_code"); 17 | form.append("redirect_uri", Deno.env.get("REDIRECT_URI")!); 18 | const res = await fetch("https://oauth2.googleapis.com/token", { 19 | method: "POST", 20 | body: form, 21 | }); 22 | const resp = await res.json(); 23 | const accessToken = resp.access_token; 24 | const refreshToken = resp.refresh_token; 25 | const accessTokenExpiresIn = resp.expires_in; 26 | const idToken = resp.id_token; 27 | const idTokenPayload = parsePayload(idToken); 28 | const email = idTokenPayload.email; 29 | 30 | const user = await getOrCreateUserByEmail(email); 31 | user.googleRefreshToken = refreshToken; 32 | user.googleAccessToken = accessToken; 33 | user.googleAccessTokenExpires = new Date( 34 | Date.now() + accessTokenExpiresIn * 1000, 35 | ); 36 | user.picture = idTokenPayload.picture; 37 | user.name = idTokenPayload.name; 38 | user.givenName = idTokenPayload.given_name || ""; 39 | user.familyName = idTokenPayload.family_name || ""; 40 | await saveUser(user); 41 | 42 | const token = await createNewTokenForUser(user); 43 | 44 | // Successfully authorized, redirect to onbording process 45 | return new Response("", { 46 | status: 303, 47 | headers: { 48 | "Location": "/mypage/onboarding", 49 | "Set-Cookie": `token=${token}; HttpOnly; Path=/`, 50 | }, 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /utils/firestore.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | // @deno-types="https://cdn.esm.sh/v83/firebase@9.8.1/app/dist/app/index.d.ts" 4 | import { initializeApp } from "https://www.gstatic.com/firebasejs/9.8.1/firebase-app.js"; 5 | // @deno-types="https://cdn.esm.sh/v83/firebase@9.8.1/firestore/dist/firestore/index.d.ts" 6 | import { 7 | addDoc, 8 | collection, 9 | connectFirestoreEmulator, 10 | deleteDoc, 11 | doc, 12 | Firestore, 13 | getDoc, 14 | getDocs, 15 | getFirestore, 16 | query, 17 | QuerySnapshot, 18 | setDoc, 19 | where, 20 | } from "https://www.gstatic.com/firebasejs/9.8.1/firebase-firestore.js"; 21 | 22 | export let firestore: Firestore; 23 | 24 | export function initFirestore(): Firestore { 25 | if (firestore) { 26 | return firestore; 27 | } 28 | const app = initializeApp({ 29 | apiKey: Deno.env.get("FIREBASE_API_KEY"), 30 | authDomain: Deno.env.get("FIREBASE_AUTH_DOMAIN"), 31 | projectId: Deno.env.get("FIREBASE_PROJECT_ID"), 32 | storageBucket: Deno.env.get("FIREBASE_STORAGE_BUCKET"), 33 | messagingSenderId: Deno.env.get("FIREBASE_MESSING_SENDER_ID"), 34 | appId: Deno.env.get("FIREBASE_APP_ID"), 35 | measurementId: Deno.env.get("FIREBASE_MEASUREMENT_ID"), 36 | }); 37 | firestore = getFirestore(app); 38 | return firestore; 39 | } 40 | 41 | /** Switches to the emulator, used in testing */ 42 | export function useEmulator( 43 | firestore: Firestore, 44 | hostname = "localhost", 45 | port = 8080, 46 | ) { 47 | connectFirestoreEmulator(firestore, hostname, port); 48 | } 49 | 50 | export { 51 | addDoc, 52 | collection, 53 | deleteDoc, 54 | doc, 55 | getDoc, 56 | getDocs, 57 | query, 58 | setDoc, 59 | where, 60 | }; 61 | 62 | export function getFirstData( 63 | snapshot: QuerySnapshot, 64 | ): T | undefined { 65 | if (snapshot.empty) { 66 | return undefined; 67 | } else { 68 | return snapshot.docs[0].data() as T; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /routes/_app.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import type { ReactNode } from "react"; 5 | import { forwardProps, useData } from "aleph/react"; 6 | import { Header } from "layout/Header.tsx"; 7 | import { Footer } from "layout/Footer.tsx"; 8 | import { getUserByToken, type User } from "utils/db.ts"; 9 | import { NotificationProvider } from "base/Notification.tsx"; 10 | import { ok } from "utils/api.ts"; 11 | 12 | export const data = { 13 | async get(_: Request, ctx: Context) { 14 | const token = ctx.cookies.get("token"); 15 | const user = token ? await getUserByToken(token) : undefined; 16 | return ok({ 17 | clientId: Deno.env.get("CLIENT_ID"), 18 | redirectUri: Deno.env.get("REDIRECT_URI"), 19 | user, 20 | }); 21 | }, 22 | }; 23 | 24 | export default function App({ children }: { children?: ReactNode }) { 25 | const { data: { clientId, redirectUri, user }, reload: reloadUser } = useData< 26 | { clientId: string; redirectUri: string; user: User | undefined } 27 | >(); 28 | 29 | const signin = () => { 30 | google.accounts.oauth2.initCodeClient({ 31 | client_id: clientId, 32 | scope: "email profile openid https://www.googleapis.com/auth/calendar", 33 | redirect_uri: redirectUri, 34 | ux_mode: "redirect", 35 | state: Math.random().toString(36).slice(2), 36 | }).requestCode(); 37 | }; 38 | 39 | return ( 40 | <> 41 | 42 |
47 |
48 | {forwardProps(children, { 49 | clientId, 50 | redirectUri, 51 | signin, 52 | user, 53 | reloadUser, 54 | })} 55 |
56 |
57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /components/icons/Close.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | type IconProps = { 5 | size?: number; 6 | className?: string; 7 | }; 8 | 9 | export default function Icon({ size = 16, className }: IconProps) { 10 | return ( 11 | 19 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/base/Copyable.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import type { PropsWithChildren } from "react"; 5 | import { useRef, useState } from "react"; 6 | import icons from "../icons/mod.ts"; 7 | import { copyToClipboard } from "utils/clipboard.ts"; 8 | 9 | type CopyableProps = PropsWithChildren<{ 10 | value?: string; 11 | className?: string; 12 | showCopiedText?: boolean; 13 | }>; 14 | 15 | export function useClipboard() { 16 | const [copied, setCopied] = useState(false); 17 | 18 | const copy = async (text: string) => { 19 | if (!copied) { 20 | if (await copyToClipboard(text)) { 21 | setCopied(true); 22 | } 23 | setTimeout(() => setCopied(false), 2000); 24 | } 25 | }; 26 | 27 | return { copied, copyToClipboard: copy }; 28 | } 29 | 30 | export default function Copyable( 31 | { value, className, showCopiedText = false, children }: CopyableProps, 32 | ) { 33 | const parentRef = useRef(null); 34 | const { copied, copyToClipboard } = useClipboard(); 35 | 36 | return ( 37 |
41 | {children} 42 | 59 | {copied && showCopiedText && ( 60 | Copied! 61 | )} 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /components/base/Input.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import type { 5 | InputHTMLAttributes, 6 | PropsWithChildren, 7 | ReactNode, 8 | Ref, 9 | } from "react"; 10 | import { forwardRef } from "react"; 11 | 12 | export type InputProps = PropsWithChildren< 13 | & { 14 | prefix?: ReactNode; 15 | suffix?: ReactNode; 16 | rounded?: boolean; 17 | } 18 | & Omit, "onChange" | "type"> 19 | & ( 20 | { 21 | type?: "text" | "password" | "email" | "tel" | "url" | "search"; 22 | onChange?: (value: string) => void; 23 | } | { 24 | type: "number"; 25 | onChange?: (value: number) => void; 26 | } 27 | ) 28 | >; 29 | 30 | export default forwardRef((props: InputProps, ref: Ref) => { 31 | const { 32 | prefix, 33 | suffix, 34 | rounded, 35 | className, 36 | style, 37 | children, 38 | type, 39 | onChange, 40 | autoComplete = "off", 41 | ...rest 42 | } = props; 43 | return ( 44 | 71 | ); 72 | }); 73 | -------------------------------------------------------------------------------- /components/base/Button.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { 5 | AnchorHTMLAttributes, 6 | ButtonHTMLAttributes, 7 | PropsWithChildren, 8 | useMemo, 9 | } from "react"; 10 | import cx from "utils/cx.ts"; 11 | 12 | type ButtonProps = PropsWithChildren< 13 | { 14 | href?: string; 15 | style?: 16 | | "primary" 17 | | "secondary" 18 | | "danger" 19 | | "alternate" 20 | | "outline" 21 | | "none"; 22 | disabled?: boolean; 23 | size?: "xs" | "md"; 24 | } & Omit, "style" | "disabled"> 25 | >; 26 | 27 | export default function Button( 28 | { className, children, style, disabled, size = "md", ...rest }: ButtonProps, 29 | ) { 30 | const btnClassName = useMemo(() => { 31 | const base = "inline-flex items-center gap-2 transition"; 32 | return [ 33 | base, 34 | size === "md" && "px-5 py-2", 35 | size === "xs" && "px-1 py-1", 36 | style === "primary" && "rounded-full bg-primary text-black", 37 | style === "outline" && "rounded-md text-white border border-gray-600", 38 | disabled && "opacity-60 cursor-not-allowed", 39 | !disabled && "hover:opacity-80", 40 | ].filter(Boolean).join(" "); 41 | }, [disabled, style]); 42 | 43 | return ( 44 | 51 | ); 52 | } 53 | 54 | const ICON_BUTTON_BASE = 55 | "flex items-center justify-center hover:bg-gray-200/60 rounded-full w-6 h-6"; 56 | 57 | export function IconButton( 58 | { children, className, disabled, size: _, style: __, href, ...rest }: 59 | ButtonProps, 60 | ) { 61 | return ( 62 | 69 | ); 70 | } 71 | 72 | type IconLinkProps = PropsWithChildren>; 73 | 74 | export function IconLink({ children, className, ...rest }: IconLinkProps) { 75 | return ( 76 | 77 | {children} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /tools/test_get_user_availability.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { parse } from "https://deno.land/std@0.143.0/flags/mod.ts"; 4 | import { getUserAvailability, User } from "../utils/db.ts"; 5 | import { CALENDAR_FREE_BUSY_API, TOKEN_ENDPOINT } from "../utils/const.ts"; 6 | import "https://deno.land/std@0.143.0/dotenv/load.ts"; 7 | 8 | function usage() { 9 | console.log( 10 | `Usage: ./tools/test_get_user_availability.ts --start --end --email --access-token --refresh-token --expires 11 | Note: This tool checks the behavior of getUserAvailability function against actual google OAuth token endpoint`, 12 | ); 13 | } 14 | 15 | const args = parse(Deno.args, { 16 | string: ["access-token", "refresh-token", "expires", "start", "end", "email"], 17 | boolean: ["h"], 18 | }); 19 | const start = args.start; 20 | const end = args.end; 21 | const email = args.email; 22 | const accessToken = args["access-token"]; 23 | const refreshToken = args["refresh-token"]; 24 | const expires = args.expires; 25 | if (args.h) { 26 | usage(); 27 | Deno.exit(0); 28 | } 29 | if (!accessToken || !refreshToken || !expires || !start || !end) { 30 | usage(); 31 | Deno.exit(1); 32 | } 33 | const user: User = { 34 | id: crypto.randomUUID(), 35 | email, 36 | name: "test user", 37 | givenName: "test", 38 | familyName: "user", 39 | picture: "avatar.png", 40 | slug: "foo", 41 | googleRefreshToken: args["refresh-token"], 42 | googleAccessToken: args["access-token"], 43 | googleAccessTokenExpires: new Date(args["expires"]), 44 | timeZone: "Europe/London", 45 | availabilities: [{ 46 | weekDay: "MON", 47 | startTime: "09:00", 48 | endTime: "17:00", 49 | }, { 50 | weekDay: "TUE", 51 | startTime: "09:00", 52 | endTime: "17:00", 53 | }, { 54 | weekDay: "WED", 55 | startTime: "09:00", 56 | endTime: "17:00", 57 | }, { 58 | weekDay: "THU", 59 | startTime: "09:00", 60 | endTime: "17:00", 61 | }, { 62 | weekDay: "FRI", 63 | startTime: "09:00", 64 | endTime: "17:00", 65 | }], 66 | eventTypes: [], 67 | }; 68 | const availability = await getUserAvailability( 69 | user, 70 | new Date(start), 71 | new Date(end), 72 | { 73 | tokenEndpoint: TOKEN_ENDPOINT, 74 | freeBusyApi: CALENDAR_FREE_BUSY_API, 75 | }, 76 | ); 77 | console.log(availability); 78 | -------------------------------------------------------------------------------- /components/icons/Deno.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | export default function Deno(props: { className?: string }) { 5 | return ( 6 | 13 | Deno logo 14 | 15 | 19 | 20 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /routes/$user/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { useEffect } from "react"; 5 | import { useData, useRouter } from "aleph/react"; 6 | import { type EventType, getUserBySlug } from "utils/db.ts"; 7 | import { MIN } from "utils/datetime.ts"; 8 | import Badge from "base/Badge.tsx"; 9 | 10 | export const data = { 11 | async get(_req: Request, ctx: Context) { 12 | const slug = ctx.params.user; 13 | const user = await getUserBySlug(slug); 14 | if (!user) { 15 | return Response.json({ error: { message: "User not found" } }); 16 | } 17 | // Passes only necessary info 18 | return Response.json({ 19 | picture: user?.picture, 20 | name: user?.name, 21 | givenName: user?.givenName, 22 | slug: user?.slug, 23 | eventTypes: user?.eventTypes, 24 | }); 25 | }, 26 | }; 27 | 28 | export default function () { 29 | const { redirect } = useRouter(); 30 | const { data } = useData< 31 | { 32 | picture?: string; 33 | name?: string; 34 | givenName?: string; 35 | slug: string; 36 | eventTypes: EventType[]; 37 | error?: { message: string }; 38 | } 39 | >(); 40 | 41 | useEffect(() => { 42 | if (data.error) { 43 | redirect("/"); 44 | } 45 | }, []); 46 | if (data.error) { 47 | return null; 48 | } 49 | 50 | return ( 51 |
52 |

53 | {data.picture && ( 54 | 55 | )} 56 | {data.name} 57 |

58 |

59 | Welcome to my booking page. Please follow the instructions to add an 60 | event to my calendar. 61 |

62 |
63 | {data.eventTypes!.map((et) => ( 64 | 68 | {et.duration / MIN} min 69 |

{et.title}

70 |

{et.description}

71 |
72 | ))} 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /components/base/Modal.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { useCallback, useEffect, useState } from "react"; 5 | import { usePortal } from "aleph/react"; 6 | import cx from "utils/cx.ts"; 7 | import { onKeyDown } from "utils/hooks.ts"; 8 | import icons from "../icons/mod.ts"; 9 | 10 | type ModalProps = React.PropsWithChildren<{ 11 | className?: string; 12 | showCloseButton?: boolean; 13 | onESC?: () => void; 14 | }>; 15 | 16 | export default function Modal(props: ModalProps) { 17 | const { className, showCloseButton, onESC, children } = props; 18 | const portal = usePortal({ 19 | className: "modal-overlay", 20 | lockScroll: true, 21 | isDialog: true, 22 | }); 23 | const [tansitionTiggered, setTansitionTiggered] = useState(false); 24 | 25 | onKeyDown( 26 | (e) => e.key.toLowerCase() === "escape", 27 | useCallback(() => onESC?.(), [onESC]), 28 | ); 29 | 30 | useEffect(() => { 31 | let timer: number | null = setTimeout(() => { 32 | timer = null; 33 | setTansitionTiggered(true); 34 | }, 1000 / 60); 35 | return () => { 36 | timer && clearTimeout(timer); 37 | }; 38 | }, []); 39 | 40 | return portal( 41 |
42 |
52 |
64 | {children} 65 | {showCloseButton && tansitionTiggered && ( 66 | 74 | )} 75 |
76 |
, 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /components/base/Notification.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { useEffect, useState } from "react"; 5 | import { usePortal } from "aleph/react"; 6 | import cx from "utils/cx.ts"; 7 | import events from "utils/events.ts"; 8 | import { Danger, Info } from "./Status.tsx"; 9 | 10 | type Message = { 11 | type: "danger" | "success"; 12 | title: string; 13 | message: string; 14 | details?: string[]; 15 | }; 16 | 17 | export function notify(msg: Message) { 18 | events.emit("notification", msg); 19 | } 20 | 21 | export function NotificationProvider() { 22 | const [queue, setQueue] = useState<(Message & { key: number })[]>([]); 23 | const portal = usePortal({ className: "notification-overlay" }); 24 | 25 | useEffect(() => { 26 | const listener = (e: Record) => { 27 | const key = Date.now(); 28 | setQueue((queue) => [...queue, { ...e as Message, key }]); 29 | setTimeout(() => { 30 | setQueue((queue) => queue.filter((m) => m.key !== key)); 31 | }, 8000); 32 | }; 33 | 34 | events.on("notification", listener); 35 | 36 | return () => { 37 | events.off("notification", listener); 38 | }; 39 | }, []); 40 | 41 | if (queue.length === 0) { 42 | return null; 43 | } 44 | 45 | return portal( 46 |
47 | {queue.map((msg) => ( 48 | msg.type === "success" 49 | ? 50 | : 51 | ))} 52 |
, 53 | ); 54 | } 55 | 56 | function useTransitionFlag() { 57 | const [state, setState] = useState(false); 58 | useEffect(() => { 59 | setTimeout(() => { 60 | setState(true); 61 | }, 20); 62 | }, []); 63 | return state; 64 | } 65 | 66 | function DangerMessage({ msg }: { msg: Message & { key: number } }) { 67 | const flag = useTransitionFlag(); 68 | return ( 69 |
79 | 80 |
81 |

82 | {msg.title} 83 |

84 |

85 | {msg.message} 86 |

87 |
88 |
89 | ); 90 | } 91 | 92 | function SuccessMessage({ msg }: { msg: Message & { key: number } }) { 93 | const flag = useTransitionFlag(); 94 | 95 | return ( 96 |
106 | 107 |
108 |

109 | {msg.title} 110 |

111 |

112 | {msg.message} 113 |

114 |
115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./doc/screenshot.png) 2 | 3 | # Meet Me 4 | 5 | > [calendly](https://calendly.com/) clone in Deno 6 | 7 | ## Google Trust and Safety verification 8 | 9 | This app uses Google Calendar API and the app is now being reviewed by Google's 10 | Trust and Safety team for verification. If you'd like to try this app by 11 | yourself at this point, please click the below links and approve the app on your 12 | own risk while signing in to the app. 13 | 14 | ![](./doc/screenshot_consent_screen0.png) 15 | 16 | ![](./doc/screenshot_consent_screen1.png) 17 | 18 | ## Development 19 | 20 | First copy `.env.example` to `.env` and set `CLIENT_ID`, `CLIENT_SECRET`, and 21 | `FIREBASE_*` appropriate values. 22 | 23 | Then run the deployment locally: 24 | 25 | ```sh 26 | deno task dev 27 | ``` 28 | 29 | This starts Meet Me service in your local machine. 30 | 31 | ## Testing 32 | 33 | Start the firestore emulator by the below command (You need Node.js and Java > 34 | 11 to run the emulator): 35 | 36 | ``` 37 | deno task firestore-emulator 38 | ``` 39 | 40 | In another terminal window, run the below command to run the unit tests: 41 | 42 | ``` 43 | deno task test 44 | ``` 45 | 46 | ## Visual Design 47 | 48 | https://www.figma.com/file/P0XsTDIeiwNhm8jFS03gwz/Deno-Cal 49 | 50 | ## LICENSE 51 | 52 | MIT License 53 | 54 | ## Notes 55 | 56 | ### How to configure GCP Resources 57 | 58 | You need [Google Cloud Platform](https://console.cloud.google.com/) Project to 59 | develop this app. 60 | 61 | - First go to [GCP Console](https://console.cloud.google.com/) and create a 62 | project. 63 | - Then go to `APIs & Services`. 64 | - Enable Calendar API from `+ ENABLE APIS AND SERVICES` link. 65 | ![](doc/enable-api.png) 66 | - In `OAuth consent screen` tab, set up the project's consent screen. 67 | - In `Credentials` tab, create `OAuth client ID` with `Web application` type. 68 | - Under Authorized JavaScript origins add `http://localhost:3000` 69 | - Under Authorized redirect URIs add `http://localhost:3000/api/authorize` 70 | - Then you'll find client id and client secret of the oauth client. 71 | - Copy those values and set them as `CLIENT_ID` and `CLIENT_SECRET` in `.env` 72 | 73 | Now setup Firebase: 74 | 75 | - Go to https://console.firebase.google.com/ and click Create a project. 76 | - Select the project you created above. 77 | - Select your preferred billing and analytics options. 78 | - Wait while your Firebase app is created. 79 | - From the Overview screen add a Web app (currently represented with a `` 80 | icon). 81 | - Don't add Firebase hosting, as you'll be using Deno Deploy. 82 | - You'll be presented with some JavaScript code including `firebaseConfig`. Copy 83 | those values to the appropriate place in `.env`. (If you didn't enable 84 | analytics there will not be a value for `FIREBASE_MEASUREMENT_ID`). 85 | - Click back to the Overview screen, click Cloud Firestore, and then Create 86 | database. 87 | - Start in production mode. 88 | - Select a Firestore location, the default is probably good, and then Enable. 89 | - Click to the Rules tab, copy the content of `firestore.rules`, and click 90 | Publish. 91 | - Now head to Overview, then Storage. You might need to click See all Build 92 | features. 93 | - Under the Rules tab, copy the content of `storage.rules`, and click Publish. 94 | 95 | You should now be able to start the app locally with the instructions in 96 | [Development](#development). If you see a an error similar to 97 | `Could not reach Cloud Firestore backend` then you may need to wait awhile for 98 | Firestore to be available. 99 | 100 | For Deno Land employees: 101 | 102 | - You can find these values in `Meet Me API Credentials` section in the password 103 | manager. 104 | -------------------------------------------------------------------------------- /routes/api/user.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { badRequest, ok } from "utils/api.ts"; 4 | import { 5 | createNewTokenForUser, 6 | getOrCreateUserByEmail, 7 | getUserBySlug, 8 | getUserByToken, 9 | isValidEventType, 10 | isValidRange, 11 | saveUser, 12 | unavailableUserSlugs, 13 | } from "utils/db.ts"; 14 | import { isValidTimeZone } from "utils/datetime.ts"; 15 | 16 | /** Gets a user. Used only for testing. 17 | * 18 | * TODO(kt3k): Disable/remove this on production */ 19 | export const GET = async (_req: Request, ctx: Context) => { 20 | const token = ctx.cookies.get("token"); 21 | 22 | if (!token) { 23 | return badRequest("No session token is given."); 24 | } 25 | 26 | const user = await getUserByToken(token); 27 | return ok(user); 28 | }; 29 | 30 | /** Creates a user. Used only for testing. 31 | * 32 | * TODO(kt3k): Disable/remove this on production */ 33 | export const POST = async (req: Request) => { 34 | const { email } = await req.json(); 35 | const user = await getOrCreateUserByEmail(email); 36 | const token = await createNewTokenForUser(user); 37 | return ok({ token }); 38 | }; 39 | 40 | /** Updates user's info */ 41 | export const PATCH = async (req: Request, ctx: Context) => { 42 | const token = ctx.cookies.get("token"); 43 | 44 | if (!token) { 45 | return badRequest("No session token is given."); 46 | } 47 | 48 | const user = await getUserByToken(token); 49 | if (!user) { 50 | return badRequest("There's no user for the given token"); 51 | } 52 | 53 | const { slug, eventTypes, timeZone, availabilities } = await req.json(); 54 | // Updates slug 55 | if (slug) { 56 | if (!/^[0-9A-Za-z-_]+$/.test(slug)) { 57 | return badRequest( 58 | `The given slug "${slug}" includes invalid characters. The slug can contain only alphabets, numbers, -, and _.`, 59 | ); 60 | } 61 | if (unavailableUserSlugs.includes(slug)) { 62 | return badRequest(`The given slug "${slug}" is not available`); 63 | } 64 | const someoneThatHasSlug = await getUserBySlug(slug); 65 | if (someoneThatHasSlug && someoneThatHasSlug.id !== user.id) { 66 | return badRequest(`The given slug "${slug}" is not available`); 67 | } 68 | user.slug = slug; 69 | } 70 | 71 | // Updates eventTypes 72 | if (eventTypes) { 73 | if (!Array.isArray(eventTypes)) { 74 | return badRequest( 75 | `"eventTypes" need to be an array. "${typeof eventTypes}" was given.`, 76 | ); 77 | } 78 | 79 | const slugs = new Set(); 80 | for (const eventType of eventTypes) { 81 | if (!isValidEventType(eventType)) { 82 | return badRequest( 83 | `The given eventType is invalid: ${JSON.stringify(eventType)}.`, 84 | ); 85 | } 86 | const { slug } = eventType; 87 | if (!slug) { 88 | continue; 89 | } 90 | if (slugs.has(slug)) { 91 | return badRequest( 92 | `More than 1 event type have the same url slug: ${slug}.`, 93 | ); 94 | } 95 | slugs.add(slug); 96 | } 97 | // eventType.slug has to be unique 98 | user.eventTypes = eventTypes; 99 | } 100 | 101 | // Updates timeZone 102 | if (timeZone) { 103 | if (!isValidTimeZone(timeZone)) { 104 | return badRequest(`The given "timeZone" is invalid: ${timeZone}`); 105 | } 106 | user.timeZone = timeZone; 107 | } 108 | 109 | // Updates availabilities 110 | if (availabilities) { 111 | if (!Array.isArray(availabilities)) { 112 | return badRequest( 113 | `"availabilities" need to be an array. "${typeof eventTypes}" was given.`, 114 | ); 115 | } 116 | 117 | for (const range of availabilities) { 118 | if (!isValidRange(range)) { 119 | return badRequest( 120 | `The given "range" is invalid: ${JSON.stringify(range)}`, 121 | ); 122 | } 123 | } 124 | user.availabilities = availabilities; 125 | } 126 | 127 | await saveUser(user); 128 | return ok(); 129 | }; 130 | -------------------------------------------------------------------------------- /components/base/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import type { 5 | CSSProperties, 6 | MouseEvent, 7 | PropsWithChildren, 8 | ReactElement, 9 | } from "react"; 10 | import { 11 | Children, 12 | cloneElement, 13 | useEffect, 14 | useMemo, 15 | useRef, 16 | useState, 17 | } from "react"; 18 | import { usePortal } from "aleph/react"; 19 | 20 | type DropdownProps = PropsWithChildren<{ 21 | trigger: "hover" | "click"; 22 | position?: "left" | "right" | "center"; 23 | offset?: number; 24 | render: (props: { width: number; height: number }) => ReactElement | null; 25 | onTrigger?: (event: MouseEvent) => void; 26 | }>; 27 | 28 | /** Dropdown component, which can be used to show a dropdown menu. */ 29 | export default function Dropdown(props: DropdownProps) { 30 | const { trigger, onTrigger, children } = props; 31 | const [dropdown, setDropdown] = useState<{ rect: DOMRect } | null>(null); 32 | const triggerRef = useRef(); 33 | const clone = useMemo(() => { 34 | if (typeof globalThis.document !== "undefined") { 35 | const child = Children.only(children) as ReactElement; 36 | const props: Record = {}; 37 | const triggerEvent = trigger === "hover" ? "onMouseEnter" : "onClick"; 38 | props[triggerEvent] = (e: MouseEvent) => { 39 | onTrigger?.(e); 40 | if (e.defaultPrevented) { 41 | return; 42 | } 43 | 44 | if (trigger === "click") { 45 | triggerRef.current = e.target as HTMLElement; 46 | } 47 | 48 | const rect = e.currentTarget.getBoundingClientRect(); 49 | setDropdown((prev) => { 50 | if (!prev) { 51 | return { rect }; 52 | } 53 | return null; 54 | }); 55 | }; 56 | if (trigger === "hover") { 57 | props.onMouseLeave = () => { 58 | setDropdown(null); 59 | }; 60 | } 61 | return cloneElement(child, props); 62 | } 63 | return children; 64 | }, [children, trigger, onTrigger]); 65 | 66 | useEffect(() => { 67 | if (trigger === "click" && dropdown) { 68 | const onclick = (e: Event) => { 69 | e.target !== triggerRef.current && setDropdown(null); 70 | }; 71 | document.addEventListener("click", onclick); 72 | return () => { 73 | document.removeEventListener("click", onclick); 74 | }; 75 | } 76 | }, [trigger, dropdown]); 77 | 78 | return ( 79 | <> 80 | {clone} 81 | {dropdown && } 82 | 83 | ); 84 | } 85 | 86 | function DropdownModal({ 87 | offset = 8, 88 | position, 89 | rect, 90 | render, 91 | trigger, 92 | }: DropdownProps & { 93 | rect: DOMRect; 94 | }) { 95 | const portal = usePortal({ className: "modal-overlay" }); 96 | const el = useMemo(() => render(rect), [render, rect]); 97 | const [style, setStyle] = useState(() => { 98 | const init = { 99 | top: rect.bottom + offset + window.scrollY, 100 | opacity: 0, 101 | transition: "0.2s opacity ease-in-out", 102 | }; 103 | if (trigger === "click") { 104 | init.top += 8; 105 | init.transition += ", 0.2s top ease-in-out"; 106 | } 107 | switch (position) { 108 | case "center": 109 | return { 110 | left: rect.left, 111 | width: rect.width, 112 | display: "flex", 113 | justifyContent: "center", 114 | ...init, 115 | }; 116 | case "right": 117 | return { 118 | right: window.innerWidth - rect.right, 119 | ...init, 120 | }; 121 | default: 122 | return { 123 | left: rect.left, 124 | ...init, 125 | }; 126 | } 127 | }); 128 | 129 | useEffect(() => { 130 | // set a timeout to trigger the css transition 131 | setTimeout(() => { 132 | setStyle((s) => ({ 133 | ...s, 134 | opacity: 1, 135 | top: rect.bottom + offset + window.scrollY, 136 | })); 137 | }, 1000 / 60); 138 | }, []); 139 | 140 | return portal(
{el}
); 141 | } 142 | -------------------------------------------------------------------------------- /components/shared/EventTypeCard.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { useState } from "react"; 5 | import { MIN } from "utils/datetime.ts"; 6 | import { IconButton, IconLink } from "base/Button.tsx"; 7 | import Copyable from "base/Copyable.tsx"; 8 | import Badge from "base/Badge.tsx"; 9 | import { notify } from "base/Notification.tsx"; 10 | import icons from "icons"; 11 | import EditEventTypeDialog from "shared/EditEventTypeDialog.tsx"; 12 | import { EventType, UserForClient as User } from "utils/db.ts"; 13 | 14 | export default function EventTypeCard( 15 | { user, reloadUser, eventType }: { 16 | user: User; 17 | reloadUser: () => Promise; 18 | eventType: EventType; 19 | }, 20 | ) { 21 | const [updating, setUpdating] = useState(false); 22 | 23 | const removeEventTypes = async (idToRemove: string) => { 24 | setUpdating(true); 25 | try { 26 | // deno-lint-ignore no-explicit-any 27 | let eventTypes: EventType[] = (globalThis as any).structuredClone( 28 | user.eventTypes, 29 | ); 30 | eventTypes = eventTypes.filter((et) => et.id !== idToRemove); 31 | const resp = await fetch("/api/user", { 32 | method: "PATCH", 33 | headers: { 34 | "content-type": "application/json", 35 | }, 36 | body: JSON.stringify({ eventTypes }), 37 | }); 38 | const data = await resp.json(); 39 | if (!resp.ok) { 40 | return new Error(data.message); 41 | } 42 | await reloadUser(); 43 | } catch (e) { 44 | notify({ 45 | title: "Request failed", 46 | type: "danger", 47 | message: e.message, 48 | }); 49 | } finally { 50 | setUpdating(false); 51 | } 52 | }; 53 | 54 | const eventPath = `/${user.slug}/${eventType.slug || eventType.id}`; 55 | const eventUrl = `https://meet-me.deno.dev${eventPath}`; 56 | 57 | return ( 58 |
62 |
63 |
64 | 65 | {Math.floor(eventType.duration / MIN)} min 66 | 67 |
68 |
69 | 70 | 71 | 72 | 73 | 80 | 81 | 82 | 83 | 84 | { 86 | if ( 87 | confirm( 88 | `Are you sure to delete the event type "${eventType.title}"`, 89 | ) 90 | ) { 91 | removeEventTypes(eventType.id); 92 | } 93 | }} 94 | disabled={updating} 95 | > 96 | 97 | 98 |
99 |
100 |

{eventType.title}

101 |

102 | {eventType.description} 103 |

104 |
105 | ); 106 | } 107 | 108 | export function NewEventTypeCard( 109 | { user, reloadUser }: { user: User; reloadUser: () => Promise }, 110 | ) { 111 | return ( 112 | 117 |
122 | 123 |
124 |

+ New Meetings

125 |

126 | Create a new event type 127 |

128 |
129 |
130 |
131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /components/layout/Header.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import type { PropsWithChildren } from "react"; 5 | import { Link, useRouter } from "aleph/react"; 6 | import { IconLink } from "base/Button.tsx"; 7 | import Dropdown from "base/Dropdown.tsx"; 8 | import Copyable from "base/Copyable.tsx"; 9 | import { ShadowBox } from "base/Container.tsx"; 10 | import icons from "icons"; 11 | import type { User } from "utils/db.ts"; 12 | 13 | export function Header( 14 | { signin, user }: { signin: () => void; user: User | undefined }, 15 | ) { 16 | const { url } = useRouter(); 17 | const { pathname } = url; 18 | const isMyPage = pathname === "/mypage"; 19 | const isInMyPage = pathname === "/mypage" || pathname === "/mypage/settings"; 20 | const isInLandingPage = pathname === "/"; 21 | 22 | return ( 23 |
24 |
25 | 26 | 27 | {" "} 28 | {isMyPage ? "My Meetings" : "Meet Me"} 29 |
30 |
31 | {user && isInMyPage && } 32 | {isInLandingPage && } 33 |
34 |
35 | ); 36 | } 37 | 38 | function UserAccountButton({ user }: { user: User }) { 39 | const displayName = user.givenName || user.name; 40 | return ( 41 | } 45 | > 46 | 52 | 53 | ); 54 | } 55 | 56 | function UserDropdown({ user }: { user: User }) { 57 | return ( 58 | 59 |
60 | 61 | {user.name} 62 |
63 | 64 | meet-me.deno.dev/{user.slug} 65 | 66 | 67 | 68 | 69 | 70 |
71 |
72 |
    73 | 74 | Settings 75 | 76 | 79 | Go to Google Calendar 80 | 81 | 82 | Source code 83 | 84 | 85 | Sign out 86 | 87 |
88 |
89 | ); 90 | } 91 | 92 | function UserDropdownMenuItem({ 93 | href, 94 | native, 95 | children, 96 | }: PropsWithChildren<{ 97 | href: string; 98 | native?: boolean; 99 | }>) { 100 | const external = href.startsWith("https://"); 101 | return ( 102 |
  • 103 | {external || native ? {children} : ( 104 | 105 | {children} 106 | 107 | )} 108 | {external && } 109 |
  • 110 | ); 111 | } 112 | 113 | function GoogleSignInButton({ signin }: { signin: () => void }) { 114 | return ( 115 | 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /components/base/Dialog.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import type { PropsWithChildren, ReactElement, ReactNode } from "react"; 5 | import { 6 | Children, 7 | cloneElement, 8 | useCallback, 9 | useEffect, 10 | useMemo, 11 | useState, 12 | } from "react"; 13 | import events from "utils/events.ts"; 14 | import { onKeyDown } from "utils/hooks.ts"; 15 | import Modal from "./Modal.tsx"; 16 | import Button from "./Button.tsx"; 17 | import icons from "../icons/mod.ts"; 18 | 19 | type DialogProps = PropsWithChildren<{ 20 | title: ReactNode; 21 | message?: ReactNode; 22 | danger?: boolean; 23 | cancelText?: string; 24 | okText?: string; 25 | okDisabled?: boolean; 26 | onCancel?: () => void; 27 | onOk?: () => Promise | void | boolean; 28 | showCloseButton?: boolean; 29 | }>; 30 | 31 | export function dialog(props: Omit) { 32 | events.emit("dash:dialog", props); 33 | } 34 | 35 | export function DialogProvider() { 36 | const [currentDialog, setCurrentDialog] = useState(null); 37 | 38 | useEffect(() => { 39 | const listener = (e: Record) => { 40 | setCurrentDialog(e as DialogProps); 41 | }; 42 | 43 | events.on("dash:dialog", listener); 44 | 45 | return () => { 46 | events.off("dash:dialog", listener); 47 | }; 48 | }, []); 49 | 50 | if (currentDialog === null) { 51 | return null; 52 | } 53 | 54 | return ( 55 | setCurrentDialog(null)} 58 | /> 59 | ); 60 | } 61 | 62 | export default function Dialog({ 63 | children, 64 | ...rest 65 | }: DialogProps) { 66 | const [visible, setVisisble] = useState(false); 67 | const handler = useMemo( 68 | () => 69 | cloneElement(Children.only(children) as ReactElement, { 70 | onClick: () => { 71 | setVisisble(true); 72 | }, 73 | }), 74 | [children], 75 | ); 76 | 77 | return ( 78 | <> 79 | {handler} 80 | {visible && ( 81 | setVisisble(false)} 84 | /> 85 | )} 86 | 87 | ); 88 | } 89 | 90 | export function DialogModal({ 91 | title, 92 | message, 93 | danger, 94 | cancelText = "Cancel", 95 | okText = "OK", 96 | okDisabled, 97 | onCancel, 98 | onOk, 99 | onESC, 100 | showCloseButton, 101 | }: DialogProps & { onESC?: () => void }) { 102 | const [isWaiting, setIsWaiting] = useState(false); 103 | 104 | const onOkClick = useCallback(async () => { 105 | if (okDisabled || isWaiting) { 106 | return; 107 | } 108 | try { 109 | const ret = onOk?.(); 110 | if (ret === false) { 111 | return; 112 | } 113 | if (ret && ret instanceof Promise) { 114 | setIsWaiting(true); 115 | const result = await ret; 116 | setIsWaiting(false); 117 | if (result === false) { 118 | return; 119 | } 120 | } 121 | onESC?.(); 122 | } catch { 123 | setIsWaiting(false); 124 | } 125 | }, [onOk, onESC, okDisabled, isWaiting]); 126 | 127 | onKeyDown((e) => e.key.toLowerCase() === "enter", onOkClick); 128 | 129 | return ( 130 | !isWaiting && onESC?.()} 132 | className="p-6 w-full md:!w-auto md:min-w-140" 133 | showCloseButton={showCloseButton} 134 | > 135 | {title && ( 136 |
    137 |

    138 | {title} 139 |

    140 |
    141 | )} 142 | {message && ( 143 |
    144 | {message} 145 |
    146 | )} 147 |
    148 | 159 | 167 |
    168 |
    169 | ); 170 | } 171 | -------------------------------------------------------------------------------- /components/shared/EditEventTypeDialog.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { PropsWithChildren, useState } from "react"; 5 | import { MIN } from "utils/datetime.ts"; 6 | import Input from "base/Input.tsx"; 7 | import Dialog from "base/Dialog.tsx"; 8 | import Select from "base/Select.tsx"; 9 | import { notify } from "base/Notification.tsx"; 10 | import { EventType, UserForClient as User } from "utils/db.ts"; 11 | 12 | export default function EditEventTypeDialog( 13 | { children, eventTypeId, user, reloadUser }: PropsWithChildren< 14 | { eventTypeId?: string; user: User; reloadUser: () => Promise } 15 | >, 16 | ) { 17 | const editIndex = user.eventTypes!.findIndex((et) => et.id === eventTypeId); 18 | const editEventType = user.eventTypes![editIndex]; 19 | const [updating, setUpdating] = useState(false); 20 | const [title, setTitle] = useState(editEventType?.title ?? ""); 21 | const [duration, setDuration] = useState( 22 | typeof editEventType?.duration === "number" 23 | ? editEventType.duration / MIN 24 | : 30, 25 | ); 26 | const [description, setDescription] = useState( 27 | editEventType?.description ?? "", 28 | ); 29 | const [slug, setSlug] = useState(editEventType?.slug ?? ""); 30 | const disabled = updating; 31 | const updateEventTypes = async () => { 32 | setUpdating(false); 33 | try { 34 | // deno-lint-ignore no-explicit-any 35 | const eventTypes: EventType[] = (globalThis as any).structuredClone( 36 | user.eventTypes, 37 | ); 38 | const newEventType = { 39 | id: eventTypeId ?? crypto.randomUUID(), 40 | title, 41 | description, 42 | duration: duration * MIN, 43 | slug, 44 | }; 45 | if (editIndex === -1) { 46 | eventTypes.push(newEventType); 47 | } else { 48 | eventTypes[editIndex] = newEventType; 49 | } 50 | const resp = await fetch("/api/user", { 51 | method: "PATCH", 52 | headers: { 53 | "content-type": "application/json", 54 | }, 55 | body: JSON.stringify({ eventTypes }), 56 | }); 57 | const data = await resp.json(); 58 | if (!resp.ok) { 59 | throw new Error(data.message); 60 | } 61 | await reloadUser(); 62 | } catch (e) { 63 | notify({ 64 | title: "Request failed", 65 | type: "danger", 66 | message: e.message, 67 | }); 68 | return false; 69 | } finally { 70 | setUpdating(false); 71 | } 72 | }; 73 | return ( 74 | 81 |
    82 |
    83 |

    TITLE

    84 | 91 |
    92 |
    93 |

    DURATION

    94 | 110 |
    111 |
    112 |
    113 |

    DESCRIPTION

    114 | 123 |
    124 |
    125 |

    URL

    126 |

    Choose your event's url

    127 |

    128 | https://meet-me.deno.dev/{user.slug}/{" "} 129 | 130 |

    131 |
    132 |
    133 | } 134 | > 135 | {children} 136 | 137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /components/shared/AvailabilitySettings.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { 5 | HOUR, 6 | hourMinuteToSec, 7 | MIN, 8 | secToHourMinute, 9 | SELECTABLE_MINUTES, 10 | WeekDay, 11 | } from "utils/datetime.ts"; 12 | import { WeekRange, weekRangeListToMap } from "utils/datetime.ts"; 13 | import cx from "utils/cx.ts"; 14 | import Button from "base/Button.tsx"; 15 | import Select from "base/Select.tsx"; 16 | import icons from "icons"; 17 | 18 | type Props = { 19 | availabilities: WeekRange[]; 20 | onChange: (ranges: WeekRange[]) => void; 21 | }; 22 | 23 | export default function AvailabilitySettings( 24 | { availabilities, onChange }: Props, 25 | ) { 26 | const rangeMap = weekRangeListToMap(availabilities); 27 | return ( 28 |
      29 | {Object.entries(rangeMap).map(([weekDay, ranges]) => ( 30 | { 35 | const newAvailabilities = Object.values({ 36 | ...rangeMap, 37 | [weekDay]: r, 38 | }).flat(); 39 | onChange(newAvailabilities); 40 | }} 41 | /> 42 | ))} 43 |
    44 | ); 45 | } 46 | 47 | function WeekRow( 48 | { weekDay, ranges, onRangeUpdate }: { 49 | weekDay: WeekDay; 50 | ranges: WeekRange[]; 51 | onRangeUpdate: (ranges: WeekRange[]) => void; 52 | }, 53 | ) { 54 | const noRanges = ranges.length === 0; 55 | 56 | const onChange = (v: string, type: "startTime" | "endTime", i: number) => { 57 | const result = [...ranges]; 58 | result[i] = { ...ranges[i], [type]: v }; 59 | onRangeUpdate(result); 60 | }; 61 | 62 | const onRemove = (i: number) => { 63 | const result = [...ranges]; 64 | result.splice(i, 1); 65 | onRangeUpdate(result); 66 | }; 67 | 68 | const onPlus = () => { 69 | const lastRange = ranges.at(-1); 70 | if (!lastRange) { 71 | onRangeUpdate([{ weekDay, startTime: "09:00", endTime: "17:00" }]); 72 | return; 73 | } 74 | const lastEndTime = hourMinuteToSec(lastRange.endTime)!; 75 | const newStartTime = Math.min(lastEndTime + HOUR, 24 * HOUR - 15 * MIN); 76 | const newEndTime = Math.min(newStartTime + HOUR, 24 * HOUR - 15 * MIN); 77 | const newRange = { 78 | weekDay, 79 | startTime: secToHourMinute(newStartTime), 80 | endTime: secToHourMinute(newEndTime), 81 | }; 82 | const result = [...ranges, newRange]; 83 | onRangeUpdate(result); 84 | }; 85 | 86 | return ( 87 |
    92 | {noRanges && ( 93 | <> 94 |
    95 | {weekDay} 96 | Unavailable 97 |
    98 |
    99 | 102 |
    103 | 104 | )} 105 | {!noRanges && ( 106 |
    107 | {ranges.map(({ startTime, endTime }, i) => ( 108 |
    109 |
    110 | {i === 0 && weekDay} 111 | 121 | ~ 122 | 132 | 140 |
    141 |
    142 | {i === 0 && ( 143 | 146 | )} 147 |
    148 |
    149 | ))} 150 |
    151 | )} 152 |
    153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /routes/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { useEffect, useState } from "react"; 5 | import { useForwardProps, useRouter } from "aleph/react"; 6 | import icons from "icons"; 7 | import cx from "utils/cx.ts"; 8 | import { type User } from "utils/db.ts"; 9 | 10 | export default function LandingPage() { 11 | const { signin, user } = useForwardProps< 12 | { signin: () => void; user: User } 13 | >(); 14 | const { redirect } = useRouter(); 15 | 16 | useEffect(() => { 17 | if (user) { 18 | if ( 19 | user.slug !== undefined && 20 | user.availabilities !== undefined && 21 | user.timeZone !== undefined 22 | ) { 23 | redirect("/mypage"); 24 | } else { 25 | redirect("/mypage/onboarding"); 26 | } 27 | } 28 | }, []); 29 | 30 | if (user) { 31 | return null; 32 | } 33 | 34 | return ( 35 |
    36 |
    37 |

    38 | 39 | 40 | 41 |

    42 |

    43 | The Calendar by Deno 44 |

    45 |

    46 | This app showcases the use of{" "} 47 | 52 | Deno Deploy 53 | {" "} 54 | with{" "} 55 | 60 | Google OAuth API 61 | {" "} 62 | integration. It uses{" "} 63 | 68 | Aleph.js 69 | {" "} 70 | as frontend & backend framework and{" "} 71 | 76 | Cloud Firestore 77 | {" "} 78 | for the persistence. 79 |

    80 | 87 |
    88 | 89 |
    90 | ); 91 | } 92 | 93 | function Dots() { 94 | const [isClient, setIsClient] = useState(false); 95 | const [opacity, setOpacity] = useState(0); 96 | const thr = 0.03; 97 | const rows = 9; 98 | 99 | useEffect(() => { 100 | setIsClient(true); 101 | setTimeout(() => { 102 | setOpacity(1); 103 | }, 1000 / 60); 104 | }, []); 105 | 106 | if (!isClient) { 107 | return null; 108 | } 109 | 110 | return ( 111 |
    115 | {[...Array(rows)].map((_, i) => ( 116 |
    120 | {[...Array(100)].map((_, j) => { 121 | const r = Math.random(); 122 | const key = `${i}-${j}`; 123 | const className = r < thr 124 | ? "bg-red-600" 125 | : r < thr * 2 126 | ? "bg-blue-600" 127 | : r < thr * 3 128 | ? "bg-yellow-600" 129 | : "bg-gray-100/80"; 130 | if (i < 5) { 131 | return ( 132 |
    136 | ); 137 | } 138 | if (i === 5 && j < 10) { 139 | return ( 140 |
    144 | ); 145 | } 146 | 147 | return ( 148 |
    152 | ); 153 | })} 154 |
    155 | ))} 156 |
    157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Meet Me Privacy Policy 2 | 3 | Deno Land Inc ("Deno", "company", "we", "our", "us") is a corporation registered 4 | in Delaware, USA doing business as "Deno". This privacy policy will explain how 5 | our organization uses the personal data we collect from you when you use Meet Me 6 | ("Service"). 7 | 8 | Topics: 9 | 10 | - What data do we collect? 11 | - How do we collect your data? 12 | - How will we use your data? 13 | - How do we store your data? 14 | - Marketing 15 | - What are your data protection rights? 16 | - What are cookies? 17 | - How do we use cookies? 18 | - What types of cookies do we use? 19 | - How to manage your cookies 20 | - Privacy policies of other websites 21 | - Changes to our privacy policy 22 | - How to contact us 23 | - What data do we collect? 24 | 25 | ## What data do we collect? 26 | 27 | For users of Meet Me, we collect your email address and Google login. We also 28 | automatically collect from you your usage information, cookies, and device 29 | information, subject, where necessary, to your consent. 30 | 31 | ## How do we collect your data? 32 | 33 | - Your email address is collected during registration to Meet Me. 34 | - Usage information, cookies, and device information are collected automatically 35 | when viewing our websites. 36 | 37 | ## How will we use your data? 38 | 39 | Deno collects your data so that we can: 40 | 41 | - Manage your account. Meet Me requires your email address for notifications, 42 | like when being invited an organization. 43 | - Analyze usage patterns of our products and services. 44 | 45 | Deno will not share your information with other parties. 46 | 47 | ## How do we store your data? 48 | 49 | Deno takes measures reasonably necessary to protect User Personal Information 50 | from unauthorized access, alteration, or destruction. 51 | 52 | Deno will keep your email address indefinitely. Contact us at privacy@deno.com 53 | to request your information be deleted. 54 | 55 | ## Marketing 56 | 57 | Deno does not currently send out marketing information. However, Deno may in the 58 | future send you information about products and services of ours that we think 59 | you might like. 60 | 61 | You have the right at any time to stop Deno from contacting you for marketing 62 | purposes. Please contact us at privacy@deno.com 63 | 64 | ## What are your data protection rights? 65 | 66 | Deno would like to make sure you are fully aware of all of your data protection 67 | rights. Every user is entitled to the following: 68 | 69 | - The right to access – You have the right to request Deno for copies of your 70 | personal data. 71 | 72 | - The right to rectification – You have the right to request that Deno correct 73 | any information you believe is inaccurate. You also have the right to request 74 | Deno to complete the information you believe is incomplete. 75 | 76 | - The right to erasure – You have the right to request that Deno erase your 77 | personal data, under certain conditions. 78 | 79 | - The right to restrict processing – You have the right to request that Our 80 | Company restrict the processing of your personal data, under certain 81 | conditions. 82 | 83 | - The right to object to processing – You have the right to object to Our 84 | Company’s processing of your personal data, under certain conditions. 85 | 86 | - The right to data portability – You have the right to request that Deno 87 | transfer the data that we have collected to another organization, or directly 88 | to you, under certain conditions. 89 | 90 | - If you make a request, we have one month to respond to you. If you would like 91 | to exercise any of these rights, please contact us at our email: 92 | privacy@deno.com 93 | 94 | ## Cookies 95 | 96 | Cookies are text files placed on your computer to collect standard Internet log 97 | information and visitor behavior information. When you visit our websites, we 98 | may collect information from you automatically through cookies or similar 99 | technology 100 | 101 | For further information, visit allaboutcookies.org. 102 | 103 | ## How do we use cookies? 104 | 105 | Deno uses cookies in a range of ways to improve your experience on our website, 106 | including: 107 | 108 | - Keeping you signed in 109 | - Understanding how you use our website 110 | 111 | ## What types of cookies do we use? 112 | 113 | There are a number of different types of cookies, however, our website uses: 114 | 115 | - Functionality – Deno uses these cookies so that we recognize you on our 116 | website and remember your previously selected preferences. These could include 117 | what language you prefer and location you are in. A mix of first-party and 118 | third-party cookies are used. 119 | - Understanding Usage – Deno uses these cookies to collect information about 120 | your visit to our website, the content you viewed, the links you followed and 121 | information about your browser, device, and your IP address. 122 | 123 | ## How to manage cookies 124 | 125 | You can set your browser not to accept cookies, and the above website tells you 126 | how to remove cookies from your browser. However, in a few cases, some of our 127 | website features may not function as a result. 128 | 129 | ## Privacy policies of other websites 130 | 131 | The Deno website contains links to other websites. Our privacy policy applies 132 | only to deno.land and deno.com, so if you click on a link to another website, 133 | you should read their privacy policy. 134 | 135 | ## Changes to our privacy policy 136 | 137 | Deno keeps its privacy policy under regular review and places any updates on 138 | this web page. This privacy policy was last updated on December 2 2021. 139 | 140 | ## How to contact us 141 | 142 | If you have any questions about Deno’s privacy policy, the data we hold on you, 143 | or you would like to exercise one of your data protection rights, please do not 144 | hesitate to contact us at privacy@deno.com. 145 | -------------------------------------------------------------------------------- /routes/mypage/settings.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { PropsWithChildren, useEffect, useState } from "react"; 4 | import { useForwardProps, useRouter } from "aleph/react"; 5 | import { UserForClient as User } from "utils/db.ts"; 6 | import Button from "base/Button.tsx"; 7 | import { notify } from "base/Notification.tsx"; 8 | import TimeZoneSelect from "shared/TimeZoneSelect.tsx"; 9 | import AvailabilitySettings from "shared/AvailabilitySettings.tsx"; 10 | import cx from "utils/cx.ts"; 11 | import { isValidTimeZone, TimeZone } from "utils/datetime.ts"; 12 | import { equal } from "std/testing/asserts.ts"; 13 | 14 | export default function Settings() { 15 | const { user, reloadUser } = useForwardProps< 16 | { user: User; reloadUser: () => Promise } 17 | >(); 18 | 19 | const { redirect } = useRouter(); 20 | 21 | useEffect(() => { 22 | if (!user) { 23 | redirect("/"); 24 | } 25 | }); 26 | 27 | if (!user) { 28 | return null; 29 | } 30 | 31 | return ( 32 |
    33 | Back 34 | 35 | 36 | 37 |
    38 | ); 39 | } 40 | 41 | function SettingsBox( 42 | { className, children, title, description }: PropsWithChildren< 43 | { className?: string; title: string; description?: string } 44 | >, 45 | ) { 46 | return ( 47 |
    48 |

    {title}

    49 | {description &&

    {description}

    } 50 | {children} 51 |
    52 | ); 53 | } 54 | 55 | type SettingsProps = { user: User; reloadUser: () => Promise }; 56 | 57 | export function SlugSettings({ user, reloadUser }: SettingsProps) { 58 | const [slug, setSlug] = useState(user.slug || ""); 59 | const [updating, setUpdating] = useState(false); 60 | 61 | const updateSlug = async () => { 62 | setUpdating(true); 63 | try { 64 | const res = await fetch("/api/user", { 65 | method: "PATCH", 66 | body: JSON.stringify({ slug }), 67 | }); 68 | const data = await res.json(); 69 | if (!res.ok) { 70 | throw new Error(data.message); 71 | } 72 | notify({ 73 | title: "URL updated!", 74 | type: "success", 75 | message: 76 | `Your Meet Me URL has been updated to meet-me.deno.dev/${slug}`, 77 | }); 78 | reloadUser(); 79 | } catch (e) { 80 | notify({ 81 | title: "Request failed", 82 | type: "danger", 83 | message: e.message, 84 | }); 85 | } finally { 86 | setUpdating(false); 87 | } 88 | }; 89 | 90 | return ( 91 | 95 |
    96 | https://meet-me.deno.dev/ 97 | { 101 | setSlug(e.target.value); 102 | }} 103 | value={slug} 104 | /> 105 | 113 |
    114 |
    115 | ); 116 | } 117 | 118 | export function TimeZoneSettings({ user, reloadUser }: SettingsProps) { 119 | const initialTimeZone = isValidTimeZone(user.timeZone!) 120 | ? user.timeZone 121 | : "Europe/London"; 122 | const [timeZone, setTimeZone] = useState(initialTimeZone); 123 | 124 | const updateTimeZone = async (timeZone: string) => { 125 | try { 126 | const resp = await fetch("/api/user", { 127 | method: "PATCH", 128 | body: JSON.stringify({ 129 | timeZone, 130 | }), 131 | }); 132 | const data = await resp.json(); 133 | if (!resp.ok) { 134 | throw new Error(data.message); 135 | } 136 | notify({ 137 | title: "Time zone updated!", 138 | type: "success", 139 | message: `Your time zone has been updated to ${timeZone}.`, 140 | }); 141 | reloadUser(); 142 | } catch (e) { 143 | notify({ 144 | title: "Request failed", 145 | type: "danger", 146 | message: e.message, 147 | }); 148 | } 149 | }; 150 | 151 | return ( 152 | 153 | { 156 | setTimeZone(timeZone); 157 | updateTimeZone(timeZone); 158 | }} 159 | /> 160 | 161 | ); 162 | } 163 | 164 | export function AvailabilitySettingsArea({ user, reloadUser }: SettingsProps) { 165 | const [updating, setUpdating] = useState(false); 166 | const [availabilities, setAvailabilities] = useState(user.availabilities!); 167 | 168 | const updateAvailability = async () => { 169 | setUpdating(true); 170 | try { 171 | const res = await fetch("/api/user", { 172 | method: "PATCH", 173 | body: JSON.stringify({ availabilities }), 174 | }); 175 | const data = await res.json(); 176 | if (!res.ok) { 177 | throw new Error(data.message); 178 | } 179 | notify({ 180 | title: "Availability updated!", 181 | type: "success", 182 | message: `Your availability has been updated.`, 183 | }); 184 | reloadUser(); 185 | } catch (e) { 186 | notify({ 187 | title: "Request failed", 188 | type: "danger", 189 | message: e.message, 190 | }); 191 | } finally { 192 | setUpdating(false); 193 | } 194 | }; 195 | 196 | return ( 197 | 198 | 202 | 210 | 211 | ); 212 | } 213 | -------------------------------------------------------------------------------- /TERMS.md: -------------------------------------------------------------------------------- 1 | # Meet Me Terms of Service 2 | 3 | ## Introduction 4 | 5 | Deno Land Inc ("Deno", "company", "we", "our", "us") is a corporation registered 6 | in Delaware, USA doing business as "Deno". 7 | 8 | These Terms of Service ("Terms") govern your use of our service Meet Me 9 | ("Service") located at https://meet-me.deno.dev operated by Deno Land Inc doing 10 | business as Deno. 11 | 12 | Our Privacy Policy also governs your use of our Service and explains how we 13 | collect, safeguard and disclose information that results from your use of our 14 | web pages. Please read it here https://meet-me.deno.dev/privacy. 15 | 16 | Your agreement with us includes these Terms and our Privacy Policy 17 | ("Agreements"). You acknowledge that you have read and understood Agreements, 18 | and agree to be bound of them. 19 | 20 | ## Prohibited Uses 21 | 22 | You may use Service only for lawful purposes and in accordance with Terms. You 23 | agree not to use Service: 24 | 25 | - In any way that violates any applicable national or international law or 26 | regulation. 27 | - For the purpose of exploiting, harming, or attempting to exploit or harm 28 | minors in any way by exposing them to inappropriate content or otherwise. 29 | - To transmit, or procure the sending of, any advertising or promotional 30 | material, including any "junk mail", "chain letter", "spam", or any other 31 | similar solicitation. 32 | - To impersonate or attempt to impersonate Company, a Company employee, another 33 | user, or any other person or entity. 34 | - In any way that infringes upon the rights of others, or in any way is illegal, 35 | threatening, fraudulent, or harmful, or in connection with any unlawful, 36 | illegal, fraudulent, or harmful purpose or activity. 37 | - To engage in any other conduct that restricts or inhibits anyone's use or 38 | enjoyment of Service, or which, as determined by us, may harm or offend 39 | Company or users of Service or expose them to liability. 40 | 41 | Additionally, you agree not to: 42 | 43 | - Use Service in any manner that could disable, overburden, damage, or impair 44 | Service or interfere with any other party's use of Service, including their 45 | ability to engage in real time activities through Service. 46 | - Use any robot, spider, or other automatic device, process, or means to access 47 | Service for any purpose, including monitoring or copying any of the material 48 | on Service. 49 | - Use any manual process to monitor or copy any of the material on Service or 50 | for any other unauthorized purpose without our prior written consent. 51 | - Use any device, software, or routine that interferes with the proper working 52 | of Service. 53 | - Introduce any viruses, trojan horses, worms, logic bombs, or other material 54 | which is malicious or technologically harmful. 55 | - Attempt to gain unauthorized access to, interfere with, damage, or disrupt any 56 | parts of Service, the server on which Service is stored, or any server, 57 | computer, or database connected to Service. 58 | - Attack Service via a denial-of-service attack or a distributed 59 | denial-of-service attack. 60 | - Take any action that may damage or falsify Company rating. 61 | - Otherwise attempt to interfere with the proper working of Service. 62 | 63 | ## Analytics 64 | 65 | We may use third-party Service Providers to monitor and analyze the use of our 66 | Service. 67 | 68 | ## No use by minors 69 | 70 | Service is intended only for access and use by individuals at least 18 years 71 | old. By accessing or using our Service, you warrant and represent that you are 72 | at least 18 years of age and with the full authority, right, and capacity to 73 | enter into this agreement and abide by all of the terms and conditions of Terms. 74 | If you are not at least 18 years old, you are prohibited from both the access 75 | and usage of Service. 76 | 77 | ## Accounts 78 | 79 | When you create an account with us, you guarantee that you are above the age of 80 | 18, and that the information you provide us is accurate, complete, and current 81 | at all times. Inaccurate, incomplete, or obsolete information may result in the 82 | immediate termination of your account on Service. 83 | 84 | We reserve the right to refuse service, terminate accounts, remove or edit 85 | content, or cancel orders in our sole discretion. 86 | 87 | ## Copyright Policy 88 | 89 | We respect the intellectual property rights of others. It is our policy to 90 | respond to any claim that Content posted on Service infringes on the copyright 91 | or other intellectual property rights ("Infringement") of any person or entity. 92 | 93 | ## Links To Other Web Sites 94 | 95 | Our Service may contain links to third party web sites or services that are not 96 | owned or controlled by us. 97 | 98 | Deno has no control over, and assumes no responsibility for the content, privacy 99 | policies, or practices of any third party web sites or services. We do not 100 | warrant the offerings of any of these entities/individuals or their websites. 101 | 102 | ## Disclaimer of Warranty 103 | 104 | THE SERVICE IS MADE AVAILABLE TO YOU ON AN "AS IS" AND "AS AVAILABLE" BASIS, 105 | WITH THE EXPRESS UNDERSTANDING THAT DENO HAS NO OBLIGATION TO MONITOR, CONTROL, 106 | OR VET THE CONTENT OR DATA APPEARING ON THE WEBSITES AND ONLINE SERVICES. AS 107 | SUCH, YOUR USE OF THE SERVICE IS AT YOUR OWN DISCRETION AND RISK. DENO MAKES NO 108 | CLAIMS OR PROMISES ABOUT THE QUALITY, ACCURACY, OR RELIABILITY OF THE SERVICE 109 | AND EXPRESSLY DISCLAIM ALL WARRANTIES, WHETHER EXPRESS OR IMPLIED, INCLUDING 110 | IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND 111 | NON-INFRINGEMENT. 112 | 113 | ## Limitation of Liability 114 | 115 | IN NO EVENT WILL DENO BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY DIRECT, 116 | INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING OUT OF 117 | OR RELATING TO YOUR ACCESS TO OR USE OF, OR YOUR INABILITY TO ACCESS OR USE, THE 118 | SERVICE OR ANY MATERIALS OR CONTENT ON THE SERVICE, WHETHER BASED ON WARRANTY, 119 | CONTRACT, TORT (INCLUDING NEGLIGENCE), STATUTE, OR ANY OTHER LEGAL THEORY, 120 | WHETHER OR NOT DENO HAS BEEN INFORMED OF THE POSSIBILITY OF SUCH DAMAGE. 121 | 122 | ## Termination 123 | 124 | We may terminate or suspend your account and bar access to Service immediately, 125 | without prior notice or liability, under our sole discretion, for any reason 126 | whatsoever and without limitation, including but not limited to a breach of 127 | Terms. 128 | 129 | If you wish to terminate your account, you may simply discontinue using Service. 130 | 131 | ## Governing Law 132 | 133 | These Terms shall be governed and construed in accordance with the laws of the 134 | State of New York without regard to its conflict of law provisions. 135 | 136 | Our failure to enforce any right or provision of these Terms will not be 137 | considered a waiver of those rights. If any provision of these Terms is held to 138 | be invalid or unenforceable by a court, the remaining provisions of these Terms 139 | will remain in effect. These Terms constitute the entire agreement between us 140 | regarding our Service and supersede and replace any prior agreements we might 141 | have had between us regarding Service. 142 | 143 | ## Changes to the Terms of Service 144 | 145 | We keep the Terms of Service under regular review and places any updates on this 146 | web page. These Terms of Service were last updated on June 24 2022. 147 | 148 | ## How to contact us 149 | 150 | If you have any questions about these Terms of Service, please do not hesitate 151 | to contact us at deploy@deno.com. 152 | -------------------------------------------------------------------------------- /utils/db.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { validate as uuidValidate } from "std/uuid/v4.ts"; 4 | import { encode as hexEncode } from "std/encoding/hex.ts"; 5 | import { 6 | getAvailableRangesBetween, 7 | hourMinuteToSec, 8 | isValidHourMinute, 9 | isValidWeekDay, 10 | MIN, 11 | Range, 12 | subtractRangeListFromRangeList, 13 | WeekRange, 14 | } from "./datetime.ts"; 15 | import { 16 | collection, 17 | doc, 18 | getDoc, 19 | getDocs, 20 | getFirstData, 21 | initFirestore as firestore, 22 | query, 23 | setDoc, 24 | where, 25 | } from "./firestore.ts"; 26 | 27 | const enc = new TextEncoder(); 28 | const dec = new TextDecoder(); 29 | 30 | /** User represents the signed-in user. */ 31 | export type User = { 32 | id: string; 33 | email: string; 34 | name?: string; 35 | givenName?: string; 36 | familyName?: string; 37 | picture?: string; 38 | slug?: string; 39 | googleRefreshToken?: string; 40 | googleAccessToken?: string; 41 | googleAccessTokenExpires?: Date; 42 | timeZone?: string; 43 | availabilities?: WeekRange[]; 44 | eventTypes?: EventType[]; 45 | }; 46 | 47 | export type UserForClient = Omit; 48 | 49 | /** EventType is a template of the events, which the users can set up. 50 | * The visiters can book actual events based on this EventType settings. */ 51 | export type EventType = { 52 | id: string; 53 | title: string; 54 | description?: string; 55 | duration: number; 56 | /** The slug is used as the last part of the booking page of this event type 57 | * like `https://meet-me.deno.dev/[user-slug]/[event-type-slug]`. 58 | */ 59 | slug?: string; 60 | }; 61 | 62 | type Token = { 63 | id: string; 64 | hash: string; 65 | userId: string; 66 | expires: Date; 67 | }; 68 | 69 | // These words are not usable as url slugs. 70 | export const unavailableUserSlugs = [ 71 | "mypage", 72 | "api", 73 | "index", 74 | "terms", 75 | "privacy", 76 | "signout", 77 | ]; 78 | 79 | /** Gets a user by the given id. */ 80 | export async function getUserById(id: string): Promise { 81 | const snapshot = await getDoc(doc(firestore(), "users", id)); 82 | if (snapshot.exists()) { 83 | return snapshot.data() as User; 84 | } else { 85 | return undefined; 86 | } 87 | } 88 | 89 | /** Gets a user by the given email. */ 90 | export async function getUserByEmail(email: string): Promise { 91 | const snapshot = await getDocs( 92 | query(collection(firestore(), "users"), where("email", "==", email)), 93 | ); 94 | return getFirstData(snapshot); 95 | } 96 | 97 | /** Gets a user by the given email. */ 98 | export async function getUserBySlug(slug: string): Promise { 99 | const snapshot = await getDocs( 100 | query(collection(firestore(), "users"), where("slug", "==", slug)), 101 | ); 102 | return getFirstData(snapshot); 103 | } 104 | 105 | function createDefaultCalendarEvent(): EventType { 106 | return { 107 | id: crypto.randomUUID(), 108 | title: "30 Minute Meeting", 109 | description: "30 minute meeting.", 110 | duration: 30 * MIN, 111 | slug: "30min", 112 | }; 113 | } 114 | 115 | export function isValidEventType(event: EventType): event is EventType { 116 | return uuidValidate(event.id) && typeof event.title === "string" && 117 | typeof event.duration === "number"; 118 | } 119 | 120 | // deno-lint-ignore no-explicit-any 121 | export function isValidRange(range: any = {}): range is WeekRange { 122 | const { weekDay, startTime, endTime } = range; 123 | return isValidWeekDay(weekDay) && 124 | isValidHourMinute(startTime) && 125 | isValidHourMinute(endTime) && 126 | hourMinuteToSec(endTime)! - hourMinuteToSec(startTime)! > 0; 127 | } 128 | 129 | /** Creates a user by the given email. This throws if there's already 130 | * a user of the given email. */ 131 | export async function createUserByEmail(email: string): Promise { 132 | const existingUser = await getUserByEmail(email); 133 | if (existingUser) { 134 | throw new Error(`The email is already userd by another user: ${email}`); 135 | } 136 | const newUser = { 137 | id: crypto.randomUUID(), 138 | email, 139 | eventTypes: [createDefaultCalendarEvent()], 140 | }; 141 | await saveUser(newUser); 142 | return newUser; 143 | } 144 | 145 | export async function getOrCreateUserByEmail(email: string): Promise { 146 | const user = await getUserByEmail(email); 147 | if (user) { 148 | return user; 149 | } 150 | return createUserByEmail(email); 151 | } 152 | 153 | export async function saveUser(user: User): Promise { 154 | await setDoc(doc(firestore(), "users", user.id), user); 155 | } 156 | 157 | /** Returns true if the user's settings are ready to start using Meet Me. 158 | * This check is used for sending user to onboarding flow. */ 159 | export function isUserReady( 160 | user: Omit | undefined, 161 | ) { 162 | if (!user) { 163 | return false; 164 | } 165 | return user.slug !== undefined && user.availabilities !== undefined && 166 | user.timeZone !== undefined; 167 | } 168 | 169 | export function isUserAuthorized(user: Pick) { 170 | return user.googleRefreshToken !== undefined; 171 | } 172 | 173 | /** Gets the availability of the user in the given period of time. */ 174 | export async function getUserAvailability( 175 | user: User, 176 | start: Date, 177 | end: Date, 178 | freeBusyApi: string, 179 | ) { 180 | const body = JSON.stringify({ 181 | timeMin: start.toISOString(), 182 | timeMax: end.toISOString(), 183 | items: [{ id: user.email }], 184 | }); 185 | const resp = await fetch(freeBusyApi, { 186 | method: "POST", 187 | body, 188 | headers: { 189 | "Content-Type": "application/json", 190 | "Authorization": `Bearer ${user.googleAccessToken}`, 191 | }, 192 | }); 193 | const data = await resp.json(); 194 | if (!resp.ok) { 195 | throw new Error(data.error.message); 196 | } 197 | const busyRanges = data.calendars[user.email].busy.map(( 198 | { start, end }: { start: string; end: string }, 199 | ) => ({ start: new Date(start), end: new Date(end) })) as Range[]; 200 | const sourceAvailableRanges = getAvailableRangesBetween( 201 | start, 202 | end, 203 | user.availabilities!, 204 | // deno-lint-ignore no-explicit-any 205 | user.timeZone as any, 206 | ); 207 | 208 | return subtractRangeListFromRangeList(sourceAvailableRanges, busyRanges); 209 | } 210 | 211 | export async function ensureAccessTokenIsFreshEnough( 212 | user: User, 213 | tokenEndpoint: string, 214 | ) { 215 | if (needsAccessTokenRefresh(user)) { 216 | const params = new URLSearchParams(); 217 | params.append("client_id", Deno.env.get("CLIENT_ID")!); 218 | params.append("client_secret", Deno.env.get("CLIENT_SECRET")!); 219 | params.append("refresh_token", user.googleRefreshToken!); 220 | params.append("grant_type", "refresh_token"); 221 | const resp = await fetch(tokenEndpoint, { 222 | method: "POST", 223 | body: params, 224 | headers: { 225 | "Content-Type": "application/x-www-form-urlencoded", 226 | }, 227 | }); 228 | if (!resp.ok) { 229 | const data = await resp.json(); 230 | throw Error(`Token refresh failed: ${data.error_description}`); 231 | } 232 | const body = await resp.json() as { 233 | access_token: string; 234 | expires_in: number; 235 | }; 236 | user.googleAccessToken = body.access_token; 237 | user.googleAccessTokenExpires = new Date( 238 | Date.now() + body.expires_in * 1000, 239 | ); 240 | await saveUser(user); 241 | } 242 | } 243 | 244 | /** Returns true if the access token needs to be refreshed */ 245 | export function needsAccessTokenRefresh( 246 | user: Pick, 247 | ): boolean { 248 | const expires = user.googleAccessTokenExpires; 249 | if (!expires) { 250 | return false; 251 | } 252 | 253 | // If the access token expires in 10 sec, then returns true. 254 | return +expires < Date.now() - 10000; 255 | } 256 | 257 | /** Gets the token by the given hash. */ 258 | export async function getTokenByHash(hash: string): Promise { 259 | const snapshot = await getDocs( 260 | query(collection(firestore(), "tokens"), where("hash", "==", hash)), 261 | ); 262 | return getFirstData(snapshot); 263 | } 264 | 265 | export async function getUserByToken(token: string) { 266 | const hash = await sha256(token); 267 | const tokenObj = await getTokenByHash(hash); 268 | if (tokenObj) { 269 | return getUserById(tokenObj.userId); 270 | } 271 | } 272 | 273 | async function sha256(str: string) { 274 | return dec.decode( 275 | hexEncode( 276 | new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(str))), 277 | ), 278 | ); 279 | } 280 | 281 | export async function createNewTokenForUser(user: User): Promise { 282 | const tokenStr = Math.random().toString(36).slice(2); 283 | const token = { 284 | id: crypto.randomUUID(), 285 | hash: await sha256(tokenStr), 286 | userId: user.id, 287 | expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), 288 | }; 289 | await setDoc(doc(firestore(), "tokens", token.id), token); 290 | 291 | return tokenStr; 292 | } 293 | -------------------------------------------------------------------------------- /routes/mypage/onboarding.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | /** @jsxImportSource https://esm.sh/react@18.2.0 */ 3 | 4 | import { useEffect, useState } from "react"; 5 | import { useForwardProps, useRouter } from "aleph/react"; 6 | import Button from "base/Button.tsx"; 7 | import icons from "icons"; 8 | import SlidingPanel, { PanelState } from "base/SlidingPanel.tsx"; 9 | import { notify } from "base/Notification.tsx"; 10 | import TimeZoneSelect from "shared/TimeZoneSelect.tsx"; 11 | import AvailabilitySettings from "shared/AvailabilitySettings.tsx"; 12 | import EventTypeCard, { NewEventTypeCard } from "shared/EventTypeCard.tsx"; 13 | import { UserForClient as User } from "utils/db.ts"; 14 | import { WeekRange } from "utils/datetime.ts"; 15 | import { delay } from "std/async/delay.ts"; 16 | 17 | const STEPS = ["slug", "availability", "eventType"] as const; 18 | type Step = (typeof STEPS)[number]; 19 | 20 | export default function OnboardingPage() { 21 | const { user, reloadUser } = useForwardProps< 22 | { user: User; reloadUser: () => Promise } 23 | >(); 24 | const { redirect } = useRouter(); 25 | const [step, setStep] = useState("slug"); 26 | const [panelState, setPanelState] = useState("center"); 27 | 28 | useEffect(() => { 29 | if (!user) { 30 | redirect("/"); 31 | } 32 | }, []); 33 | 34 | const goForward = async () => { 35 | setPanelState("right"); 36 | await delay(1000); 37 | await reloadUser(); 38 | setPanelState("left"); 39 | setStep(STEPS[STEPS.indexOf(step) + 1]); 40 | await delay(0); 41 | setPanelState("center"); 42 | }; 43 | 44 | const goBack = async () => { 45 | setPanelState("left"); 46 | await delay(1000); 47 | await reloadUser(); 48 | setPanelState("right"); 49 | setStep(STEPS[STEPS.indexOf(step) - 1]); 50 | await delay(0); 51 | setPanelState("center"); 52 | }; 53 | 54 | if (!user) { 55 | return null; 56 | } 57 | 58 | return ( 59 |
    60 | {step === "slug" && ( 61 | 62 | 66 | 67 | )} 68 | {step === "availability" && ( 69 | 70 | 75 | 76 | )} 77 | {step === "eventType" && ( 78 | 79 | 85 | 86 | )} 87 |
    88 | ); 89 | } 90 | 91 | function ChooseURL( 92 | { slug: slugDefault, onFinish }: { 93 | slug: string; 94 | onFinish: () => void; 95 | }, 96 | ) { 97 | const [slug, setSlug] = useState(slugDefault); 98 | const [updating, setUpdating] = useState(false); 99 | 100 | const updateSlug = async () => { 101 | setUpdating(true); 102 | try { 103 | const res = await fetch("/api/user", { 104 | method: "PATCH", 105 | body: JSON.stringify({ slug }), 106 | }); 107 | const data = await res.json(); 108 | if (!res.ok) { 109 | throw new Error(data.message); 110 | } 111 | onFinish(); 112 | } catch (e) { 113 | notify({ 114 | title: "Request failed", 115 | type: "danger", 116 | message: e.message, 117 | }); 118 | } finally { 119 | setUpdating(false); 120 | } 121 | }; 122 | 123 | return ( 124 | <> 125 |
    126 |

    127 | Welcome to Meet Me! 128 |

    129 |
    130 | 131 |
    132 |

    133 | Create your Meet Me URL{" "} 134 | 135 | (step 1 of 3) 136 | 137 |

    138 |

    139 | Choose a URL that describes you or your business in a concise way. 140 | Make it short and easy to remember so you can share links with ease. 141 |

    142 |
    143 | https://meet-me.deno.dev/ 144 | { 148 | setSlug(e.target.value); 149 | }} 150 | value={slug} 151 | /> 152 |
    153 |
    154 | 162 |
    163 |
    164 | 165 | ); 166 | } 167 | 168 | const DEFAULT_AVAILABILITIES: WeekRange[] = [ 169 | { weekDay: "MON" as const, startTime: "09:00", endTime: "17:00" }, 170 | { weekDay: "TUE" as const, startTime: "09:00", endTime: "17:00" }, 171 | { weekDay: "WED" as const, startTime: "09:00", endTime: "17:00" }, 172 | { weekDay: "THU" as const, startTime: "09:00", endTime: "17:00" }, 173 | { weekDay: "FRI" as const, startTime: "09:00", endTime: "17:00" }, 174 | ]; 175 | 176 | function ChooseAvailabilities( 177 | { user, onCancel, onFinish }: { 178 | user: User; 179 | onCancel: () => void; 180 | onFinish: () => void; 181 | }, 182 | ) { 183 | const [updating, setUpdating] = useState(false); 184 | const [timeZone, setTimeZone] = useState(""); 185 | 186 | const [availabilities, setAvailabilities] = useState( 187 | user.availabilities ?? DEFAULT_AVAILABILITIES, 188 | ); 189 | 190 | useEffect(() => { 191 | setTimeZone( 192 | // Set the system's default time zone as default 193 | user.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, 194 | ); 195 | }, []); 196 | 197 | const updateAvailabilities = async () => { 198 | setUpdating(true); 199 | try { 200 | const resp = await fetch("/api/user", { 201 | method: "PATCH", 202 | body: JSON.stringify({ 203 | timeZone, 204 | availabilities, 205 | }), 206 | }); 207 | const data = await resp.json(); 208 | if (!resp.ok) { 209 | throw new Error(data.message); 210 | } 211 | onFinish(); 212 | } catch (e) { 213 | notify({ 214 | title: "Request failed", 215 | type: "danger", 216 | message: e.message, 217 | }); 218 | } finally { 219 | setUpdating(false); 220 | } 221 | }; 222 | 223 | return ( 224 |
    225 |

    226 | Set your availability{" "} 227 | 228 | (step 2 of 3) 229 | 230 |

    231 | setTimeZone(timeZone)} 234 | /> 235 | 239 |
    240 | 247 | 255 |
    256 |
    257 | ); 258 | } 259 | 260 | function SetUpEventType({ user, onCancel, onFinish, reloadUser }: { 261 | user: User; 262 | onCancel: () => void; 263 | onFinish: () => void; 264 | reloadUser: () => Promise; 265 | }) { 266 | const eventTypes = user.eventTypes!; 267 | const { redirect } = useRouter(); 268 | return ( 269 |
    270 |

    271 | Set up event types{" "} 272 | 273 | (step 3 of 3) 274 | 275 |

    276 |
    277 | {eventTypes.map((eventType) => ( 278 | 284 | ))} 285 | 286 |
    287 |
    288 | 294 | 308 |
    309 |
    310 | ); 311 | } 312 | -------------------------------------------------------------------------------- /test/api_user_test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { assertEquals, assertStringIncludes } from "std/testing/asserts.ts"; 4 | import { MockServer } from "aleph/server/mock.ts"; 5 | import { MIN } from "utils/datetime.ts"; 6 | import { initFirestore, useEmulator } from "utils/firestore.ts"; 7 | import { 8 | resetEmulatorDocuments, 9 | setTestFirebaseEnvVars, 10 | } from "utils/firestore_test_util.ts"; 11 | 12 | const EMAIL = "foo@deno.com"; 13 | const EMAIL_ALT = "bar@deno.com"; 14 | 15 | Deno.test( 16 | "/api/user", 17 | { sanitizeOps: false, sanitizeResources: false }, 18 | async (t) => { 19 | setTestFirebaseEnvVars(); 20 | useEmulator(initFirestore()); 21 | await resetEmulatorDocuments(); 22 | const api = new MockServer({ 23 | router: { 24 | glob: "./routes/**/*.{ts,tsx}", 25 | }, 26 | }); 27 | 28 | await new Promise((resolve) => { 29 | (async () => { 30 | for (let i = 0; i < 100; i++) { 31 | await new Promise((resolve) => setTimeout(resolve, 100)); 32 | try { 33 | const res = await api.fetch("/api/user"); 34 | await res.body?.cancel(); 35 | resolve(); 36 | break; 37 | } catch (e) { 38 | if (e instanceof Deno.errors.PermissionDenied) { 39 | throw e; 40 | } 41 | // retry 42 | } 43 | } 44 | })(); 45 | }); 46 | 47 | // Creates user and token for testing 48 | const createUser = async (email: string) => { 49 | const resp = await api.fetch("/api/user", { 50 | method: "POST", 51 | body: `{"email":"${email}"}`, 52 | }); 53 | const { token } = await resp.json(); 54 | return token; 55 | }; 56 | const token = await createUser(EMAIL); 57 | 58 | // Util for calling PATCH /api/user 59 | const patchUser = async (obj: Record, t = token) => { 60 | const resp = await api.fetch("/api/user", { 61 | method: "PATCH", 62 | headers: { 63 | "Cookie": `token=${t}`, 64 | }, 65 | body: JSON.stringify(obj), 66 | }); 67 | const x = [resp.status, await resp.json()]; 68 | return x; 69 | }; 70 | 71 | await t.step("PATCH /api/user with slug", async () => { 72 | const [code, _] = await patchUser({ slug: "foo" }); 73 | assertEquals(code, 200); 74 | const user = await (await api.fetch("/api/user", { 75 | headers: { Cookie: `token=${token}` }, 76 | })) 77 | .json(); 78 | assertEquals(user?.slug, "foo"); 79 | }); 80 | 81 | await t.step("PATCH /api/user with invalid char in slug", async () => { 82 | const [code, { message }] = await patchUser({ slug: "%%%" }); 83 | assertEquals(code, 400); 84 | assertStringIncludes( 85 | message, 86 | `The given slug "%%%" includes invalid characters`, 87 | ); 88 | const user = await (await api.fetch("/api/user", { 89 | headers: { Cookie: `token=${token}` }, 90 | })) 91 | .json(); 92 | assertEquals(user?.slug, "foo"); // Not changed 93 | }); 94 | 95 | await t.step("PATCH /api/user with invalid name", async () => { 96 | const [code, { message }] = await patchUser({ slug: "mypage" }); 97 | assertEquals(code, 400); 98 | assertStringIncludes(message, `The given slug "mypage" is not available`); 99 | const user = await (await api.fetch("/api/user", { 100 | headers: { Cookie: `token=${token}` }, 101 | })) 102 | .json(); 103 | assertEquals(user?.slug, "foo"); // Not changed 104 | }); 105 | 106 | await t.step("PATCH /api/user with non available name", async () => { 107 | // Make user and gets `bar` for it 108 | await patchUser({ slug: "bar" }, await createUser(EMAIL_ALT)); 109 | 110 | // `bar` is not available anymore because it's taken in the above 111 | const [code, { message }] = await patchUser({ slug: "bar" }); 112 | assertEquals(code, 400); 113 | assertStringIncludes(message, `The given slug "bar" is not available`); 114 | const user = await (await api.fetch("/api/user", { 115 | headers: { Cookie: `token=${token}` }, 116 | })) 117 | .json(); 118 | assertEquals(user?.slug, "foo"); // Not changed 119 | }); 120 | 121 | await t.step("PATCH /api/user with time zone", async () => { 122 | const [code, _] = await patchUser({ timeZone: "Europe/London" }); 123 | assertEquals(code, 200); 124 | const user = await (await api.fetch("/api/user", { 125 | headers: { Cookie: `token=${token}` }, 126 | })) 127 | .json(); 128 | assertEquals(user?.timeZone, "Europe/London"); 129 | }); 130 | 131 | await t.step("PATCH /api/user with invalid time zone", async () => { 132 | const [code, { message }] = await patchUser({ timeZone: "Foo/Bar" }); 133 | assertEquals(code, 400); 134 | assertStringIncludes(message, `The given "timeZone" is invalid`); 135 | }); 136 | 137 | await t.step("PATCH /api/user with event types", async () => { 138 | const id = crypto.randomUUID(); 139 | const [code, _] = await patchUser({ 140 | eventTypes: [{ id, title: "45 Minute Meeting", duration: 45 * MIN }], 141 | }); 142 | assertEquals(code, 200); 143 | const user = await (await api.fetch("/api/user", { 144 | headers: { Cookie: `token=${token}` }, 145 | })) 146 | .json(); 147 | assertEquals(user?.eventTypes, [{ 148 | id, 149 | title: "45 Minute Meeting", 150 | duration: 45 * MIN, 151 | }]); 152 | }); 153 | 154 | await t.step("PATCH /api/user with non-array event types", async () => { 155 | const [code, { message }] = await patchUser({ 156 | eventTypes: { title: "45 Minute Meeting", duration: 45 * MIN }, 157 | }); 158 | assertEquals(code, 400); 159 | assertStringIncludes(message, `"eventTypes" need to be an array.`); 160 | }); 161 | 162 | await t.step("PATCH /api/user with invalid event types", async () => { 163 | let [code, { message }] = await patchUser({ 164 | eventTypes: [{}], 165 | }); 166 | assertEquals(code, 400); 167 | assertStringIncludes(message, `The given eventType is invalid`); 168 | 169 | // the event type misses duration 170 | [code, { message }] = await patchUser({ 171 | eventTypes: [{ id: crypto.randomUUID(), title: "foo" }], 172 | }); 173 | assertEquals(code, 400); 174 | assertStringIncludes(message, `The given eventType is invalid`); 175 | 176 | // the event type misses title 177 | [code, { message }] = await patchUser({ 178 | eventTypes: [{ id: crypto.randomUUID(), duration: 30 * MIN }], 179 | }); 180 | assertEquals(code, 400); 181 | assertStringIncludes(message, `The given eventType is invalid`); 182 | 183 | // the event type misses id 184 | [code, { message }] = await patchUser({ 185 | eventTypes: [{ title: "Foo", duration: 30 * MIN }], 186 | }); 187 | assertEquals(code, 400); 188 | assertStringIncludes(message, `The given eventType is invalid`); 189 | 190 | // the event types have non unique slugs 191 | [code, { message }] = await patchUser({ 192 | eventTypes: [ 193 | { 194 | id: crypto.randomUUID(), 195 | title: "Foo", 196 | duration: 30 * MIN, 197 | slug: "30min", 198 | }, 199 | { 200 | id: crypto.randomUUID(), 201 | title: "Bar", 202 | duration: 30 * MIN, 203 | slug: "30min", 204 | }, 205 | ], 206 | }); 207 | assertEquals(code, 400); 208 | assertStringIncludes( 209 | message, 210 | `More than 1 event type have the same url slug: 30min.`, 211 | ); 212 | }); 213 | 214 | await t.step("PATCH /api/user with availabilities", async () => { 215 | const [code, _] = await patchUser({ 216 | availabilities: [ 217 | { weekDay: "MON", startTime: "09:00", endTime: "17:00" }, 218 | { weekDay: "TUE", startTime: "09:00", endTime: "17:00" }, 219 | { weekDay: "WED", startTime: "12:00", endTime: "17:00" }, 220 | { weekDay: "THU", startTime: "09:00", endTime: "17:00" }, 221 | { weekDay: "FRI", startTime: "09:00", endTime: "12:00" }, 222 | ], 223 | }); 224 | assertEquals(code, 200); 225 | const user = await (await api.fetch("/api/user", { 226 | headers: { Cookie: `token=${token}` }, 227 | })) 228 | .json(); 229 | assertEquals(user?.availabilities, [ 230 | { weekDay: "MON", startTime: "09:00", endTime: "17:00" }, 231 | { weekDay: "TUE", startTime: "09:00", endTime: "17:00" }, 232 | { weekDay: "WED", startTime: "12:00", endTime: "17:00" }, 233 | { weekDay: "THU", startTime: "09:00", endTime: "17:00" }, 234 | { weekDay: "FRI", startTime: "09:00", endTime: "12:00" }, 235 | ]); 236 | }); 237 | 238 | await t.step("PATCH /api/user with non-array availabilities", async () => { 239 | const [code, { message }] = await patchUser({ 240 | availabilities: { 241 | weekDay: "MON", 242 | startTime: "09:00", 243 | endTime: "17:00", 244 | }, 245 | }); 246 | assertEquals(code, 400); 247 | assertStringIncludes(message, `"availabilities" need to be an array.`); 248 | }); 249 | 250 | await t.step("PATCH /api/user with invalid availabilities", async () => { 251 | { 252 | const [code, { message }] = await patchUser({ 253 | availabilities: [{ 254 | weekDay: "MON", 255 | startTime: "09:00", 256 | endTime: "FOO", 257 | }], 258 | }); 259 | assertEquals(code, 400); 260 | assertStringIncludes(message, `The given "range" is invalid`); 261 | } 262 | { 263 | const [code, { message }] = await patchUser({ 264 | availabilities: [{ 265 | weekDay: "MON", 266 | startTime: "BAR", 267 | endTime: "17:00", 268 | }], 269 | }); 270 | assertEquals(code, 400); 271 | assertStringIncludes(message, `The given "range" is invalid`); 272 | } 273 | { 274 | const [code, { message }] = await patchUser({ 275 | availabilities: [{ 276 | weekDay: "BON", 277 | startTime: "09:00", 278 | endTime: "17:00", 279 | }], 280 | }); 281 | assertEquals(code, 400); 282 | assertStringIncludes(message, `The given "range" is invalid`); 283 | } 284 | }); 285 | }, 286 | ); 287 | -------------------------------------------------------------------------------- /assets/gfm.css: -------------------------------------------------------------------------------- 1 | :root,[data-color-mode=light][data-light-theme=light],[data-color-mode=dark][data-dark-theme=light]{--color-canvas-default-transparent:rgba(255,255,255,0);--color-prettylights-syntax-comment:#6e7781;--color-prettylights-syntax-constant:#0550ae;--color-prettylights-syntax-entity:#8250df;--color-prettylights-syntax-storage-modifier-import:#24292f;--color-prettylights-syntax-entity-tag:#116329;--color-prettylights-syntax-keyword:#cf222e;--color-prettylights-syntax-string:#0a3069;--color-prettylights-syntax-variable:#953800;--color-prettylights-syntax-string-regexp:#116329;--color-prettylights-syntax-constant-other-reference-link:#0a3069;--color-fg-default:#24292f;--color-fg-muted:#57606a;--color-canvas-default:#fff;--color-canvas-subtle:#f6f8fa;--color-border-default:#d0d7de;--color-border-muted:#d8dee4;--color-neutral-muted:rgba(175,184,193,.2);--color-accent-fg:#0969da;--color-accent-emphasis:#0969da;--color-danger-fg:#cf222e}@media (prefers-color-scheme:light){[data-color-mode=auto][data-light-theme=light]{--color-canvas-default-transparent:rgba(255,255,255,0);--color-prettylights-syntax-comment:#6e7781;--color-prettylights-syntax-constant:#0550ae;--color-prettylights-syntax-entity:#8250df;--color-prettylights-syntax-storage-modifier-import:#24292f;--color-prettylights-syntax-entity-tag:#116329;--color-prettylights-syntax-keyword:#cf222e;--color-prettylights-syntax-string:#0a3069;--color-prettylights-syntax-variable:#953800;--color-prettylights-syntax-string-regexp:#116329;--color-prettylights-syntax-constant-other-reference-link:#0a3069;--color-fg-default:#24292f;--color-fg-muted:#57606a;--color-canvas-default:#fff;--color-canvas-subtle:#f6f8fa;--color-border-default:#d0d7de;--color-border-muted:#d8dee4;--color-neutral-muted:rgba(175,184,193,.2);--color-accent-fg:#0969da;--color-accent-emphasis:#0969da;--color-danger-fg:#cf222e}}@media (prefers-color-scheme:dark){[data-color-mode=auto][data-dark-theme=light]{--color-canvas-default-transparent:rgba(255,255,255,0);--color-prettylights-syntax-comment:#6e7781;--color-prettylights-syntax-constant:#0550ae;--color-prettylights-syntax-entity:#8250df;--color-prettylights-syntax-storage-modifier-import:#24292f;--color-prettylights-syntax-entity-tag:#116329;--color-prettylights-syntax-keyword:#cf222e;--color-prettylights-syntax-string:#0a3069;--color-prettylights-syntax-variable:#953800;--color-prettylights-syntax-string-regexp:#116329;--color-prettylights-syntax-constant-other-reference-link:#0a3069;--color-fg-default:#24292f;--color-fg-muted:#57606a;--color-canvas-default:#fff;--color-canvas-subtle:#f6f8fa;--color-border-default:#d0d7de;--color-border-muted:#d8dee4;--color-neutral-muted:rgba(175,184,193,.2);--color-accent-fg:#0969da;--color-accent-emphasis:#0969da;--color-danger-fg:#cf222e}}[data-color-mode=light][data-light-theme=dark],[data-color-mode=dark][data-dark-theme=dark]{--color-canvas-default-transparent:rgba(13,17,23,0);--color-prettylights-syntax-comment:#8b949e;--color-prettylights-syntax-constant:#79c0ff;--color-prettylights-syntax-entity:#d2a8ff;--color-prettylights-syntax-storage-modifier-import:#c9d1d9;--color-prettylights-syntax-entity-tag:#7ee787;--color-prettylights-syntax-keyword:#ff7b72;--color-prettylights-syntax-string:#a5d6ff;--color-prettylights-syntax-variable:#ffa657;--color-prettylights-syntax-string-regexp:#7ee787;--color-prettylights-syntax-constant-other-reference-link:#a5d6ff;--color-fg-default:#c9d1d9;--color-fg-muted:#8b949e;--color-canvas-default:#0d1117;--color-canvas-subtle:#161b22;--color-border-default:#30363d;--color-border-muted:#21262d;--color-neutral-muted:rgba(110,118,129,.4);--color-accent-fg:#58a6ff;--color-accent-emphasis:#1f6feb;--color-danger-fg:#f85149}@media (prefers-color-scheme:light){[data-color-mode=auto][data-light-theme=dark]{--color-canvas-default-transparent:rgba(13,17,23,0);--color-prettylights-syntax-comment:#8b949e;--color-prettylights-syntax-constant:#79c0ff;--color-prettylights-syntax-entity:#d2a8ff;--color-prettylights-syntax-storage-modifier-import:#c9d1d9;--color-prettylights-syntax-entity-tag:#7ee787;--color-prettylights-syntax-keyword:#ff7b72;--color-prettylights-syntax-string:#a5d6ff;--color-prettylights-syntax-variable:#ffa657;--color-prettylights-syntax-string-regexp:#7ee787;--color-prettylights-syntax-constant-other-reference-link:#a5d6ff;--color-fg-default:#c9d1d9;--color-fg-muted:#8b949e;--color-canvas-default:#0d1117;--color-canvas-subtle:#161b22;--color-border-default:#30363d;--color-border-muted:#21262d;--color-neutral-muted:rgba(110,118,129,.4);--color-accent-fg:#58a6ff;--color-accent-emphasis:#1f6feb;--color-danger-fg:#f85149}}@media (prefers-color-scheme:dark){[data-color-mode=auto][data-dark-theme=dark]{--color-canvas-default-transparent:rgba(13,17,23,0);--color-prettylights-syntax-comment:#8b949e;--color-prettylights-syntax-constant:#79c0ff;--color-prettylights-syntax-entity:#d2a8ff;--color-prettylights-syntax-storage-modifier-import:#c9d1d9;--color-prettylights-syntax-entity-tag:#7ee787;--color-prettylights-syntax-keyword:#ff7b72;--color-prettylights-syntax-string:#a5d6ff;--color-prettylights-syntax-variable:#ffa657;--color-prettylights-syntax-string-regexp:#7ee787;--color-prettylights-syntax-constant-other-reference-link:#a5d6ff;--color-fg-default:#c9d1d9;--color-fg-muted:#8b949e;--color-canvas-default:#0d1117;--color-canvas-subtle:#161b22;--color-border-default:#30363d;--color-border-muted:#21262d;--color-neutral-muted:rgba(110,118,129,.4);--color-accent-fg:#58a6ff;--color-accent-emphasis:#1f6feb;--color-danger-fg:#f85149}}.markdown-body{word-wrap:break-word;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;font-size:16px;line-height:1.5}.markdown-body:before{content:"";display:table}.markdown-body:after{clear:both;content:"";display:table}.markdown-body>:first-child{margin-top:0!important}.markdown-body>:last-child{margin-bottom:0!important}.markdown-body a:not([href]){color:inherit;text-decoration:none}.markdown-body .absent{color:var(--color-danger-fg)}.markdown-body .anchor{float:left;margin-left:-20px;padding-right:4px;line-height:1}.markdown-body .anchor:focus{outline:0}.markdown-body p,.markdown-body blockquote,.markdown-body ul,.markdown-body ol,.markdown-body dl,.markdown-body table,.markdown-body pre,.markdown-body details{margin-top:0;margin-bottom:16px}.markdown-body hr{height:.25em;background-color:var(--color-border-default);border:0;margin:24px 0;padding:0}.markdown-body blockquote{color:var(--color-fg-muted);border-left:.25em solid var(--color-border-default);padding:0 1em}.markdown-body blockquote>:first-child{margin-top:0}.markdown-body blockquote>:last-child{margin-bottom:0}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{margin-top:24px;margin-bottom:16px;font-weight:600;line-height:1.25}.markdown-body h1 .octicon-link,.markdown-body h2 .octicon-link,.markdown-body h3 .octicon-link,.markdown-body h4 .octicon-link,.markdown-body h5 .octicon-link,.markdown-body h6 .octicon-link{color:var(--color-fg-default);vertical-align:middle;visibility:hidden}.markdown-body h1:hover .anchor,.markdown-body h2:hover .anchor,.markdown-body h3:hover .anchor,.markdown-body h4:hover .anchor,.markdown-body h5:hover .anchor,.markdown-body h6:hover .anchor{text-decoration:none}.markdown-body h1:hover .anchor .octicon-link,.markdown-body h2:hover .anchor .octicon-link,.markdown-body h3:hover .anchor .octicon-link,.markdown-body h4:hover .anchor .octicon-link,.markdown-body h5:hover .anchor .octicon-link,.markdown-body h6:hover .anchor .octicon-link{visibility:visible}.markdown-body h1 tt,.markdown-body h1 code,.markdown-body h2 tt,.markdown-body h2 code,.markdown-body h3 tt,.markdown-body h3 code,.markdown-body h4 tt,.markdown-body h4 code,.markdown-body h5 tt,.markdown-body h5 code,.markdown-body h6 tt,.markdown-body h6 code{font-size:inherit;padding:0 .2em}.markdown-body h1{border-bottom:1px solid var(--color-border-muted);padding-bottom:.3em;font-size:2em}.markdown-body h2{border-bottom:1px solid var(--color-border-muted);padding-bottom:.3em;font-size:1.5em}.markdown-body h3{font-size:1.25em}.markdown-body h4{font-size:1em}.markdown-body h5{font-size:.875em}.markdown-body h6{color:var(--color-fg-muted);font-size:.85em}.markdown-body summary h1,.markdown-body summary h2,.markdown-body summary h3,.markdown-body summary h4,.markdown-body summary h5,.markdown-body summary h6{display:inline-block}.markdown-body summary h1,.markdown-body summary h2{border-bottom:0;padding-bottom:0}.markdown-body ul,.markdown-body ol{padding-left:2em}.markdown-body ul.no-list,.markdown-body ol.no-list{padding:0;list-style-type:none}.markdown-body ol[type="1"]{list-style-type:decimal}.markdown-body ol[type=a]{list-style-type:lower-alpha}.markdown-body ol[type=i]{list-style-type:lower-roman}.markdown-body div>ol:not([type]){list-style-type:decimal}.markdown-body ul ul,.markdown-body ul ol,.markdown-body ol ol,.markdown-body ol ul{margin-top:0;margin-bottom:0}.markdown-body li>p{margin-top:16px}.markdown-body li+li{margin-top:.25em}.markdown-body dl{padding:0}.markdown-body dl dt{margin-top:16px;padding:0;font-size:1em;font-style:italic;font-weight:600}.markdown-body dl dd{margin-bottom:16px;padding:0 16px}.markdown-body table{width:100%;width:max-content;max-width:100%;display:block;overflow:auto}.markdown-body table th{font-weight:600}.markdown-body table th,.markdown-body table td{border:1px solid var(--color-border-default);padding:6px 13px}.markdown-body table tr{background-color:var(--color-canvas-default);border-top:1px solid var(--color-border-muted)}.markdown-body table tr:nth-child(2n){background-color:var(--color-canvas-subtle)}.markdown-body table img{background-color:rgba(0,0,0,0)}.markdown-body img{max-width:100%;box-sizing:content-box;background-color:var(--color-canvas-default)}.markdown-body img[align=right]{padding-left:20px}.markdown-body img[align=left]{padding-right:20px}.markdown-body .emoji{max-width:none;vertical-align:text-top;background-color:rgba(0,0,0,0)}.markdown-body span.frame{display:block;overflow:hidden}.markdown-body span.frame>span{float:left;width:auto;border:1px solid var(--color-border-default);margin:13px 0 0;padding:7px;display:block;overflow:hidden}.markdown-body span.frame span img{float:left;display:block}.markdown-body span.frame span span{clear:both;color:var(--color-fg-default);padding:5px 0 0;display:block}.markdown-body span.align-center{clear:both;display:block;overflow:hidden}.markdown-body span.align-center>span{text-align:center;margin:13px auto 0;display:block;overflow:hidden}.markdown-body span.align-center span img{text-align:center;margin:0 auto}.markdown-body span.align-right{clear:both;display:block;overflow:hidden}.markdown-body span.align-right>span{text-align:right;margin:13px 0 0;display:block;overflow:hidden}.markdown-body span.align-right span img{text-align:right;margin:0}.markdown-body span.float-left{float:left;margin-right:13px;display:block;overflow:hidden}.markdown-body span.float-left span{margin:13px 0 0}.markdown-body span.float-right{float:right;margin-left:13px;display:block;overflow:hidden}.markdown-body span.float-right>span{text-align:right;margin:13px auto 0;display:block;overflow:hidden}.markdown-body code,.markdown-body tt{background-color:var(--color-neutral-muted);border-radius:6px;margin:0;padding:.2em .4em;font-size:85%}.markdown-body code br,.markdown-body tt br{display:none}.markdown-body del code{-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}.markdown-body samp{font-size:85%}.markdown-body pre{word-wrap:normal}.markdown-body pre code{font-size:100%}.markdown-body pre>code{word-break:normal;white-space:pre;background:0 0;border:0;margin:0;padding:0}.markdown-body .highlight{margin-bottom:16px}.markdown-body .highlight pre{word-break:normal;margin-bottom:0}.markdown-body .highlight pre,.markdown-body pre{background-color:var(--color-canvas-subtle);border-radius:6px;padding:16px;font-size:85%;line-height:1.45;overflow:auto}.markdown-body pre code,.markdown-body pre tt{max-width:auto;line-height:inherit;word-wrap:normal;background-color:rgba(0,0,0,0);border:0;margin:0;padding:0;display:inline;overflow:visible}.markdown-body .csv-data td,.markdown-body .csv-data th{text-align:left;white-space:nowrap;padding:5px;font-size:12px;line-height:1;overflow:hidden}.markdown-body .csv-data .blob-num{text-align:right;background:var(--color-canvas-default);border:0;padding:10px 8px 9px}.markdown-body .csv-data tr{border-top:0}.markdown-body .csv-data th{background:var(--color-canvas-subtle);border-top:0;font-weight:600}.markdown-body [data-footnote-ref]:before{content:"["}.markdown-body [data-footnote-ref]:after{content:"]"}.markdown-body .footnotes{color:var(--color-fg-muted);border-top:1px solid var(--color-border-default);font-size:12px}.markdown-body .footnotes ol{padding-left:16px}.markdown-body .footnotes li{position:relative}.markdown-body .footnotes li:target:before{pointer-events:none;content:"";border:2px solid var(--color-accent-emphasis);border-radius:6px;position:absolute;top:-8px;bottom:-8px;left:-24px;right:-8px}.markdown-body .footnotes li:target{color:var(--color-fg-default)}.markdown-body .footnotes .data-footnote-backref g-emoji{font-family:monospace}.markdown-body{background-color:var(--color-canvas-default);color:var(--color-fg-default)}.markdown-body a{color:var(--color-accent-fg);text-decoration:none}.markdown-body a:hover{text-decoration:underline}.markdown-body iframe{background-color:#fff;border:0;margin-bottom:16px}.markdown-body .highlight .token.keyword,.gfm-highlight .token.keyword{color:var(--color-prettylights-syntax-keyword)}.markdown-body .highlight .token.tag .token.class-name,.markdown-body .highlight .token.tag .token.script .token.punctuation,.gfm-highlight .token.tag .token.class-name,.gfm-highlight .token.tag .token.script .token.punctuation{color:var(--color-prettylights-syntax-storage-modifier-import)}.markdown-body .highlight .token.operator,.markdown-body .highlight .token.number,.markdown-body .highlight .token.boolean,.markdown-body .highlight .token.tag .token.punctuation,.markdown-body .highlight .token.tag .token.script .token.script-punctuation,.markdown-body .highlight .token.tag .token.attr-name,.gfm-highlight .token.operator,.gfm-highlight .token.number,.gfm-highlight .token.boolean,.gfm-highlight .token.tag .token.punctuation,.gfm-highlight .token.tag .token.script .token.script-punctuation,.gfm-highlight .token.tag .token.attr-name{color:var(--color-prettylights-syntax-constant)}.markdown-body .highlight .token.function,.gfm-highlight .token.function{color:var(--color-prettylights-syntax-entity)}.markdown-body .highlight .token.string,.gfm-highlight .token.string{color:var(--color-prettylights-syntax-string)}.markdown-body .highlight .token.comment,.gfm-highlight .token.comment{color:var(--color-prettylights-syntax-comment)}.markdown-body .highlight .token.class-name,.gfm-highlight .token.class-name{color:var(--color-prettylights-syntax-variable)}.markdown-body .highlight .token.regex,.gfm-highlight .token.regex{color:var(--color-prettylights-syntax-string)}.markdown-body .highlight .token.regex .regex-delimiter,.gfm-highlight .token.regex .regex-delimiter{color:var(--color-prettylights-syntax-constant)}.markdown-body .highlight .token.tag .token.tag,.markdown-body .highlight .token.property,.gfm-highlight .token.tag .token.tag,.gfm-highlight .token.property{color:var(--color-prettylights-syntax-entity-tag)} -------------------------------------------------------------------------------- /utils/datetime_test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Deno authors. All rights reserved. MIT license. 2 | 3 | import { 4 | createRangesInRange, 5 | daysOfMonth, 6 | getAvailableRangesBetween, 7 | HOUR, 8 | hourMinuteToSec, 9 | inRange, 10 | isValidHourMinute, 11 | MIN, 12 | rangeIsInRange, 13 | secToHourMinute, 14 | subtractRangeFromRange, 15 | subtractRangeListFromRange, 16 | subtractRangeListFromRangeList, 17 | WeekRange, 18 | weekRangeListToMap, 19 | zonedDate, 20 | } from "./datetime.ts"; 21 | import { 22 | assert, 23 | assertEquals, 24 | assertFalse, 25 | assertThrows, 26 | } from "std/testing/asserts.ts"; 27 | 28 | Deno.test("isValidHourMinute", () => { 29 | assert(isValidHourMinute("00:00")); 30 | assert(isValidHourMinute("09:59")); 31 | assert(isValidHourMinute("19:59")); 32 | assert(isValidHourMinute("23:59")); 33 | 34 | assertFalse(isValidHourMinute("00:60")); 35 | assertFalse(isValidHourMinute("24:00")); 36 | }); 37 | 38 | Deno.test("hourMinuteToSec", () => { 39 | assertEquals(hourMinuteToSec("00:00"), 0); 40 | assertEquals(hourMinuteToSec("00:01"), 1 * MIN); 41 | assertEquals(hourMinuteToSec("00:02"), 2 * MIN); 42 | assertEquals(hourMinuteToSec("01:00"), 1 * HOUR); 43 | assertEquals(hourMinuteToSec("02:00"), 2 * HOUR); 44 | assertEquals(hourMinuteToSec("23:59"), 23 * HOUR + 59 * MIN); 45 | }); 46 | 47 | Deno.test("secToHourMinute - invalid inputs", () => { 48 | assertThrows(() => { 49 | secToHourMinute(-1); 50 | }, RangeError); 51 | assertThrows(() => { 52 | secToHourMinute(24 * HOUR); 53 | }, RangeError); 54 | }); 55 | 56 | Deno.test("secToHourMinute - valid inputs", () => { 57 | assertEquals(secToHourMinute(0), "00:00"); 58 | assertEquals(secToHourMinute(1 * HOUR), "01:00"); 59 | assertEquals(secToHourMinute(2 * HOUR), "02:00"); 60 | assertEquals(secToHourMinute(12 * HOUR + 34 * MIN), "12:34"); 61 | assertEquals(secToHourMinute(23 * HOUR + 59 * MIN), "23:59"); 62 | }); 63 | 64 | Deno.test("zonedDate", () => { 65 | // London entering DST 66 | assertEquals( 67 | zonedDate("2022-03-27T00:00Z", "Europe/London"), 68 | new Date("2022-03-27T00:00Z"), 69 | ); 70 | assertEquals( 71 | zonedDate("2022-03-27T01:00Z", "Europe/London"), 72 | new Date("2022-03-27T01:00Z"), 73 | ); 74 | assertEquals( 75 | zonedDate("2022-03-27T02:00Z", "Europe/London"), 76 | new Date("2022-03-27T01:00Z"), 77 | ); 78 | assertEquals( 79 | zonedDate("2022-03-27T03:00Z", "Europe/London"), 80 | new Date("2022-03-27T02:00Z"), 81 | ); 82 | // London leaving DST 83 | assertEquals( 84 | zonedDate("2022-10-29T23:00Z", "Europe/London"), 85 | new Date("2022-10-29T22:00Z"), 86 | ); 87 | assertEquals( 88 | zonedDate("2022-10-30T00:00Z", "Europe/London"), 89 | new Date("2022-10-29T23:00Z"), 90 | ); 91 | assertEquals( 92 | zonedDate("2022-10-30T01:00Z", "Europe/London"), 93 | new Date("2022-10-30T01:00Z"), 94 | ); 95 | assertEquals( 96 | zonedDate("2022-10-30T02:00Z", "Europe/London"), 97 | new Date("2022-10-30T02:00Z"), 98 | ); 99 | // Sydney entering DST 100 | assertEquals( 101 | zonedDate("2022-10-02T01:00Z", "Australia/Sydney"), 102 | new Date("2022-10-01T15:00Z"), 103 | ); 104 | assertEquals( 105 | zonedDate("2022-10-02T02:00Z", "Australia/Sydney"), 106 | new Date("2022-10-01T16:00Z"), 107 | ); 108 | assertEquals( 109 | zonedDate("2022-10-02T03:00Z", "Australia/Sydney"), 110 | new Date("2022-10-01T16:00Z"), 111 | ); 112 | assertEquals( 113 | zonedDate("2022-10-02T04:00Z", "Australia/Sydney"), 114 | new Date("2022-10-01T17:00Z"), 115 | ); 116 | // Sydney leaving DST 117 | assertEquals( 118 | zonedDate("2023-04-02T00:00Z", "Australia/Sydney"), 119 | new Date("2023-04-01T13:00Z"), 120 | ); 121 | assertEquals( 122 | zonedDate("2023-04-02T01:00Z", "Australia/Sydney"), 123 | new Date("2023-04-01T14:00Z"), 124 | ); 125 | assertEquals( 126 | zonedDate("2023-04-02T02:00Z", "Australia/Sydney"), 127 | new Date("2023-04-01T16:00Z"), 128 | ); 129 | assertEquals( 130 | zonedDate("2023-04-02T03:00Z", "Australia/Sydney"), 131 | new Date("2023-04-01T17:00Z"), 132 | ); 133 | }); 134 | 135 | const EXAMPLE_AVAILABILITY = [ 136 | { weekDay: "MON", startTime: "09:00", endTime: "17:00" }, 137 | { weekDay: "TUE", startTime: "09:00", endTime: "17:00" }, 138 | { weekDay: "WED", startTime: "10:00", endTime: "17:00" }, 139 | { weekDay: "THU", startTime: "09:00", endTime: "18:00" }, 140 | { weekDay: "FRI", startTime: "09:00", endTime: "15:00" }, 141 | ] as WeekRange[]; 142 | 143 | Deno.test("weekRangeListToMap", () => { 144 | assertEquals( 145 | weekRangeListToMap(EXAMPLE_AVAILABILITY), 146 | { 147 | SUN: [], 148 | MON: [{ weekDay: "MON", startTime: "09:00", endTime: "17:00" }], 149 | TUE: [{ weekDay: "TUE", startTime: "09:00", endTime: "17:00" }], 150 | WED: [{ weekDay: "WED", startTime: "10:00", endTime: "17:00" }], 151 | THU: [{ weekDay: "THU", startTime: "09:00", endTime: "18:00" }], 152 | FRI: [{ weekDay: "FRI", startTime: "09:00", endTime: "15:00" }], 153 | SAT: [], 154 | }, 155 | ); 156 | }); 157 | 158 | Deno.test("getAvailableRangesBetween", () => { 159 | const start = new Date("2022-06-01Z"); 160 | const end = new Date("2022-06-16Z"); 161 | assertEquals( 162 | getAvailableRangesBetween( 163 | start, 164 | end, 165 | EXAMPLE_AVAILABILITY, 166 | "Europe/London", 167 | ), 168 | [ 169 | { 170 | start: new Date("2022-06-01T09:00Z"), 171 | end: new Date("2022-06-01T16:00Z"), 172 | }, 173 | { 174 | start: new Date("2022-06-02T08:00Z"), 175 | end: new Date("2022-06-02T17:00Z"), 176 | }, 177 | { 178 | start: new Date("2022-06-03T08:00Z"), 179 | end: new Date("2022-06-03T14:00Z"), 180 | }, 181 | { 182 | start: new Date("2022-06-06T08:00Z"), 183 | end: new Date("2022-06-06T16:00Z"), 184 | }, 185 | { 186 | start: new Date("2022-06-07T08:00Z"), 187 | end: new Date("2022-06-07T16:00Z"), 188 | }, 189 | { 190 | start: new Date("2022-06-08T09:00Z"), 191 | end: new Date("2022-06-08T16:00Z"), 192 | }, 193 | { 194 | start: new Date("2022-06-09T08:00Z"), 195 | end: new Date("2022-06-09T17:00Z"), 196 | }, 197 | { 198 | start: new Date("2022-06-10T08:00Z"), 199 | end: new Date("2022-06-10T14:00Z"), 200 | }, 201 | { 202 | start: new Date("2022-06-13T08:00Z"), 203 | end: new Date("2022-06-13T16:00Z"), 204 | }, 205 | { 206 | start: new Date("2022-06-14T08:00Z"), 207 | end: new Date("2022-06-14T16:00Z"), 208 | }, 209 | { 210 | start: new Date("2022-06-15T09:00Z"), 211 | end: new Date("2022-06-15T16:00Z"), 212 | }, 213 | ], 214 | ); 215 | }); 216 | 217 | Deno.test("getAvailableRangesBetween - cut off extra part", () => { 218 | const start = new Date("2022-06-01T10:00Z"); 219 | const end = new Date("2022-06-01T11:00Z"); 220 | assertEquals( 221 | getAvailableRangesBetween( 222 | start, 223 | end, 224 | EXAMPLE_AVAILABILITY, 225 | "Europe/London", 226 | ), 227 | [{ 228 | start: new Date("2022-06-01T10:00Z"), 229 | end: new Date("2022-06-01T11:00Z"), 230 | }], 231 | ); 232 | }); 233 | 234 | Deno.test("subtractRangeFromRange", () => { 235 | assertEquals( 236 | subtractRangeFromRange({ 237 | start: new Date("2022-06-20T09:00Z"), 238 | end: new Date("2022-06-20T17:00Z"), 239 | }, { 240 | start: new Date("2022-06-20T07:00Z"), 241 | end: new Date("2022-06-20T08:00Z"), 242 | }), 243 | [{ 244 | start: new Date("2022-06-20T09:00Z"), 245 | end: new Date("2022-06-20T17:00Z"), 246 | }], 247 | ); 248 | assertEquals( 249 | subtractRangeFromRange({ 250 | start: new Date("2022-06-20T09:00Z"), 251 | end: new Date("2022-06-20T17:00Z"), 252 | }, { 253 | start: new Date("2022-06-20T18:00Z"), 254 | end: new Date("2022-06-20T19:00Z"), 255 | }), 256 | [{ 257 | start: new Date("2022-06-20T09:00Z"), 258 | end: new Date("2022-06-20T17:00Z"), 259 | }], 260 | ); 261 | assertEquals( 262 | subtractRangeFromRange({ 263 | start: new Date("2022-06-20T09:00Z"), 264 | end: new Date("2022-06-20T17:00Z"), 265 | }, { 266 | start: new Date("2022-06-20T08:00Z"), 267 | end: new Date("2022-06-20T10:00Z"), 268 | }), 269 | [{ 270 | start: new Date("2022-06-20T10:00Z"), 271 | end: new Date("2022-06-20T17:00Z"), 272 | }], 273 | ); 274 | assertEquals( 275 | subtractRangeFromRange({ 276 | start: new Date("2022-06-20T09:00Z"), 277 | end: new Date("2022-06-20T17:00Z"), 278 | }, { 279 | start: new Date("2022-06-20T16:30Z"), 280 | end: new Date("2022-06-20T17:30Z"), 281 | }), 282 | [{ 283 | start: new Date("2022-06-20T09:00Z"), 284 | end: new Date("2022-06-20T16:30Z"), 285 | }], 286 | ); 287 | assertEquals( 288 | subtractRangeFromRange({ 289 | start: new Date("2022-06-20T09:00Z"), 290 | end: new Date("2022-06-20T17:00Z"), 291 | }, { 292 | start: new Date("2022-06-20T10:30Z"), 293 | end: new Date("2022-06-20T11:30Z"), 294 | }), 295 | [{ 296 | start: new Date("2022-06-20T09:00Z"), 297 | end: new Date("2022-06-20T10:30Z"), 298 | }, { 299 | start: new Date("2022-06-20T11:30Z"), 300 | end: new Date("2022-06-20T17:00Z"), 301 | }], 302 | ); 303 | }); 304 | 305 | Deno.test("subtractRangeListFromRange", () => { 306 | assertEquals( 307 | subtractRangeListFromRange({ 308 | start: new Date("2022-06-20T09:00Z"), 309 | end: new Date("2022-06-20T17:00Z"), 310 | }, [{ 311 | start: new Date("2022-06-20T07:00Z"), 312 | end: new Date("2022-06-20T08:00Z"), 313 | }, { 314 | start: new Date("2022-06-20T08:30Z"), 315 | end: new Date("2022-06-20T09:30Z"), 316 | }, { 317 | start: new Date("2022-06-20T10:00Z"), 318 | end: new Date("2022-06-20T11:00Z"), 319 | }, { 320 | start: new Date("2022-06-20T13:00Z"), 321 | end: new Date("2022-06-20T14:00Z"), 322 | }, { 323 | start: new Date("2022-06-20T15:30Z"), 324 | end: new Date("2022-06-20T17:00Z"), 325 | }, { 326 | start: new Date("2022-06-20T18:00Z"), 327 | end: new Date("2022-06-20T19:00Z"), 328 | }]), 329 | [{ 330 | start: new Date("2022-06-20T09:30Z"), 331 | end: new Date("2022-06-20T10:00Z"), 332 | }, { 333 | start: new Date("2022-06-20T11:00Z"), 334 | end: new Date("2022-06-20T13:00Z"), 335 | }, { 336 | start: new Date("2022-06-20T14:00Z"), 337 | end: new Date("2022-06-20T15:30Z"), 338 | }], 339 | ); 340 | }); 341 | 342 | Deno.test("subtractRangeListFromRangeList", () => { 343 | assertEquals( 344 | subtractRangeListFromRangeList([{ 345 | start: new Date("2022-06-20T09:00Z"), 346 | end: new Date("2022-06-20T17:00Z"), 347 | }, { 348 | start: new Date("2022-06-21T09:00Z"), 349 | end: new Date("2022-06-21T17:00Z"), 350 | }, { 351 | start: new Date("2022-06-22T09:00Z"), 352 | end: new Date("2022-06-22T17:00Z"), 353 | }, { 354 | start: new Date("2022-06-23T09:00Z"), 355 | end: new Date("2022-06-23T17:00Z"), 356 | }], [{ 357 | start: new Date("2022-06-20T07:00Z"), 358 | end: new Date("2022-06-20T08:00Z"), 359 | }, { 360 | start: new Date("2022-06-20T08:30Z"), 361 | end: new Date("2022-06-20T09:30Z"), 362 | }, { 363 | start: new Date("2022-06-20T10:00Z"), 364 | end: new Date("2022-06-20T11:00Z"), 365 | }, { 366 | start: new Date("2022-06-20T13:00Z"), 367 | end: new Date("2022-06-20T14:00Z"), 368 | }, { 369 | start: new Date("2022-06-20T15:30Z"), 370 | end: new Date("2022-06-20T17:00Z"), 371 | }, { 372 | start: new Date("2022-06-20T18:00Z"), 373 | end: new Date("2022-06-20T19:00Z"), 374 | }]), 375 | [{ 376 | start: new Date("2022-06-20T09:30Z"), 377 | end: new Date("2022-06-20T10:00Z"), 378 | }, { 379 | start: new Date("2022-06-20T11:00Z"), 380 | end: new Date("2022-06-20T13:00Z"), 381 | }, { 382 | start: new Date("2022-06-20T14:00Z"), 383 | end: new Date("2022-06-20T15:30Z"), 384 | }, { 385 | start: new Date("2022-06-21T09:00Z"), 386 | end: new Date("2022-06-21T17:00Z"), 387 | }, { 388 | start: new Date("2022-06-22T09:00Z"), 389 | end: new Date("2022-06-22T17:00Z"), 390 | }, { 391 | start: new Date("2022-06-23T09:00Z"), 392 | end: new Date("2022-06-23T17:00Z"), 393 | }], 394 | ); 395 | }); 396 | 397 | Deno.test("inRange", () => { 398 | assert( 399 | !inRange(new Date("2022-04Z"), new Date("2022-05Z"), new Date("2022-07Z")), 400 | ); 401 | assert( 402 | inRange(new Date("2022-05Z"), new Date("2022-05Z"), new Date("2022-07Z")), 403 | ); 404 | assert( 405 | inRange(new Date("2022-06Z"), new Date("2022-05Z"), new Date("2022-07Z")), 406 | ); 407 | assert( 408 | inRange(new Date("2022-07Z"), new Date("2022-05Z"), new Date("2022-07Z")), 409 | ); 410 | assert( 411 | !inRange(new Date("2022-08Z"), new Date("2022-05Z"), new Date("2022-07Z")), 412 | ); 413 | }); 414 | 415 | Deno.test("rangeIsInRange", () => { 416 | assert( 417 | !rangeIsInRange( 418 | { start: new Date("2017Z"), end: new Date("2019Z") }, 419 | { start: new Date("2020Z"), end: new Date("2024Z") }, 420 | ), 421 | ); 422 | assert( 423 | !rangeIsInRange( 424 | { start: new Date("2018Z"), end: new Date("2020Z") }, 425 | { start: new Date("2020Z"), end: new Date("2024Z") }, 426 | ), 427 | ); 428 | assert( 429 | !rangeIsInRange( 430 | { start: new Date("2019Z"), end: new Date("2021Z") }, 431 | { start: new Date("2020Z"), end: new Date("2024Z") }, 432 | ), 433 | ); 434 | assert( 435 | rangeIsInRange( 436 | { start: new Date("2020Z"), end: new Date("2022Z") }, 437 | { start: new Date("2020Z"), end: new Date("2024Z") }, 438 | ), 439 | ); 440 | assert( 441 | rangeIsInRange( 442 | { start: new Date("2021Z"), end: new Date("2023Z") }, 443 | { start: new Date("2020Z"), end: new Date("2024Z") }, 444 | ), 445 | ); 446 | assert( 447 | rangeIsInRange( 448 | { start: new Date("2022Z"), end: new Date("2024Z") }, 449 | { start: new Date("2020Z"), end: new Date("2024Z") }, 450 | ), 451 | ); 452 | assert( 453 | !rangeIsInRange( 454 | { start: new Date("2023Z"), end: new Date("2025Z") }, 455 | { start: new Date("2020Z"), end: new Date("2024Z") }, 456 | ), 457 | ); 458 | assert( 459 | !rangeIsInRange( 460 | { start: new Date("2024Z"), end: new Date("2026Z") }, 461 | { start: new Date("2020Z"), end: new Date("2024Z") }, 462 | ), 463 | ); 464 | assert( 465 | !rangeIsInRange( 466 | { start: new Date("2025Z"), end: new Date("2027Z") }, 467 | { start: new Date("2020Z"), end: new Date("2024Z") }, 468 | ), 469 | ); 470 | }); 471 | 472 | Deno.test("createRangesInRange", () => { 473 | assertEquals( 474 | createRangesInRange( 475 | new Date("2022-06-22T00:00Z"), 476 | new Date("2022-06-22T02:00Z"), 477 | 30 * MIN, 478 | 30 * MIN, 479 | ), 480 | [ 481 | { 482 | start: new Date("2022-06-22T00:00Z"), 483 | end: new Date("2022-06-22T00:30Z"), 484 | }, 485 | { 486 | start: new Date("2022-06-22T00:30Z"), 487 | end: new Date("2022-06-22T01:00Z"), 488 | }, 489 | { 490 | start: new Date("2022-06-22T01:00Z"), 491 | end: new Date("2022-06-22T01:30Z"), 492 | }, 493 | { 494 | start: new Date("2022-06-22T01:30Z"), 495 | end: new Date("2022-06-22T02:00Z"), 496 | }, 497 | ], 498 | ); 499 | }); 500 | 501 | Deno.test("daysOfMonth", () => { 502 | assertEquals(31, daysOfMonth(new Date("2022-01-05T00:00Z"))); 503 | assertEquals(28, daysOfMonth(new Date("2022-02-05T00:00Z"))); 504 | assertEquals(31, daysOfMonth(new Date("2022-03-05T00:00Z"))); 505 | assertEquals(30, daysOfMonth(new Date("2022-04-05T00:00Z"))); 506 | assertEquals(31, daysOfMonth(new Date("2022-05-05T00:00Z"))); 507 | assertEquals(30, daysOfMonth(new Date("2022-06-05T00:00Z"))); 508 | assertEquals(31, daysOfMonth(new Date("2022-07-05T00:00Z"))); 509 | assertEquals(31, daysOfMonth(new Date("2022-08-05T00:00Z"))); 510 | assertEquals(30, daysOfMonth(new Date("2022-09-05T00:00Z"))); 511 | assertEquals(31, daysOfMonth(new Date("2022-10-05T00:00Z"))); 512 | assertEquals(30, daysOfMonth(new Date("2022-11-05T00:00Z"))); 513 | assertEquals(31, daysOfMonth(new Date("2022-12-05T00:00Z"))); 514 | }); 515 | --------------------------------------------------------------------------------