├── .prettierignore ├── commitlint.config.cjs ├── .husky ├── commit-msg └── pre-commit ├── e2e-tests ├── .eslintrc ├── tsconfig.json ├── browserWithFixedTime.ts ├── highlight-ongoing-session.spec.ts └── session-in-local-time.spec.ts ├── renovate.json ├── src ├── FeatherIcons.module.css ├── log.ts ├── debugLog.ts ├── handleError.ts ├── TimeZoneSelector.module.css ├── DaySelector.module.css ├── toUTCTime.ts ├── Footer.module.css ├── index.tsx ├── App.module.css ├── DaySelector.tsx ├── Footer.tsx ├── sentry.ts ├── types.d.ts ├── ThemeSwitcher.tsx ├── SessionName.tsx ├── time.ts ├── Form.module.css ├── Table.module.css ├── DaySelector.spec.tsx ├── ongoingSessions.ts ├── ongoingSessions.spec.ts ├── useIcalExport.ts ├── Schedule.tsx ├── Editor.tsx ├── FeatherIcons.tsx ├── App.tsx └── timezones.tsx ├── netlify.toml ├── adr ├── 004-use-of-vite.md ├── 002-clean-gitignore.md ├── README.md ├── 003-use-of-typescript.md └── 001-use-saga-as-the-main-branch.md ├── public ├── manifest.json └── theme.css ├── .gitignore ├── tsconfig.json ├── playwright.config.ts ├── index.html ├── .github └── workflows │ └── test-and-release.yaml ├── README.md ├── vite.config.ts ├── .eslintrc └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 -------------------------------------------------------------------------------- /e2e-tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>nordicsemiconductor/asset-tracker-cloud-code-style-js"] 3 | } 4 | -------------------------------------------------------------------------------- /src/FeatherIcons.module.css: -------------------------------------------------------------------------------- 1 | .iconContainer { 2 | line-height: 1px; 3 | display: inline-block; 4 | } 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged && npx tsc && npm test -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "build" 3 | command = ''' 4 | export PUBLIC_VERSION=$COMMIT_REF 5 | npm run build; 6 | ''' 7 | [build.environment] 8 | NODE_VERSION = "20" -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | export const log = (label: string, ...args: any[]): void => 2 | console.log( 3 | `%c${label}`, 4 | 'background-color: #3543ec; color: #ffffff; padding: 0.25rem;', 5 | ...args, 6 | ) 7 | -------------------------------------------------------------------------------- /adr/004-use-of-vite.md: -------------------------------------------------------------------------------- 1 | # ADR 004: use of Vite 2 | 3 | [Vite](https://vitejs.dev/) is used as build and development tool for the web 4 | application, because it offers a fast development experience with TypeScript. 5 | -------------------------------------------------------------------------------- /src/debugLog.ts: -------------------------------------------------------------------------------- 1 | export const debugLog = (label: string, ...args: any[]): void => 2 | console.debug( 3 | `%c${label}`, 4 | 'background-color: #80cbc8; color: #000000; padding: 0.25rem;', 5 | ...args, 6 | ) 7 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Local Schedule", 3 | "name": "Local Schedule: Conference schedules in your local time", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#000000" 8 | } 9 | -------------------------------------------------------------------------------- /src/handleError.ts: -------------------------------------------------------------------------------- 1 | export const handleError = 2 | (label: string) => 3 | (error: Error): void => 4 | console.log( 5 | `%c${label} Error`, 6 | 'background-color: #cb3837; color: #ffffff; padding: 0.25rem;', 7 | error.message, 8 | error, 9 | ) 10 | -------------------------------------------------------------------------------- /src/TimeZoneSelector.module.css: -------------------------------------------------------------------------------- 1 | .TimeZoneSelector { 2 | background-color: transparent; 3 | border: 1px solid var(--color-text); 4 | padding: 0.25rem 0.5rem; 5 | height: 40px; 6 | color: var(--color-text); 7 | margin: 0; 8 | } 9 | 10 | .TimeZoneSelector option { 11 | color: black; 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file should only cover artifacts caused by the source code in this 2 | # repository, not those caused by the personal choice of editor and/or 3 | # environment of a developer. 4 | # See adr/002-clean-gitignore.md 5 | node_modules/ 6 | npm-debug.log 7 | build/ 8 | test-results/ 9 | playwright-report/ 10 | e2e-tests-out 11 | -------------------------------------------------------------------------------- /src/DaySelector.module.css: -------------------------------------------------------------------------------- 1 | .DaySelector { 2 | background-color: transparent; 3 | border: 1px solid var(--color-text); 4 | padding: 0.25rem 0.5rem; 5 | height: 30px; 6 | color: var(--color-text); 7 | margin: 0; 8 | } 9 | 10 | .DaySelector:global(::-webkit-calendar-picker-indicator) { 11 | filter: var(--filter-calendar-picker-indicator); 12 | } 13 | -------------------------------------------------------------------------------- /adr/002-clean-gitignore.md: -------------------------------------------------------------------------------- 1 | # ADR 002: Clean `.gitignore` file 2 | 3 | A `.gitignore` file in a project must only cover the artifacts caused by the 4 | contained source code and not those caused by the personal choice of editor or 5 | the environment of a developer. 6 | 7 | This is explained in detail 8 | [here](https://github.com/coderbyheart/first-principles/issues/30). 9 | -------------------------------------------------------------------------------- /adr/README.md: -------------------------------------------------------------------------------- 1 | # Architecture decision records 2 | 3 | This folder contains the architecture decision records (ADRs) for this project. 4 | 5 | To know more about ADRs, see 6 | [Documenting architecture decisions](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) 7 | and the video on 8 | [Communicating and documenting architectural decisions](https://www.youtube.com/watch?v=rwfXkSjFhzc). 9 | -------------------------------------------------------------------------------- /e2e-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "esnext", 5 | "moduleResolution": "Node", 6 | "sourceMap": true, 7 | "outDir": "../e2e-tests-out", 8 | "esModuleInterop": true, 9 | "baseUrl": "../src", 10 | "strict": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": ["../playwright.config.ts", "../e2e-tests"] 14 | } 15 | -------------------------------------------------------------------------------- /src/toUTCTime.ts: -------------------------------------------------------------------------------- 1 | import { zonedTimeToUtc } from 'date-fns-tz' 2 | 3 | export const toUTCTime = 4 | ({ 5 | conferenceDate, 6 | eventTimezoneName, 7 | }: { 8 | conferenceDate: string 9 | eventTimezoneName: string 10 | }) => 11 | (time: number): Date => { 12 | const minutes = time % 100 13 | const hours = (time - minutes) / 100 14 | return zonedTimeToUtc( 15 | `${conferenceDate} ${`${hours}`.padStart(2, '0')}:${`${minutes}`.padStart( 16 | 2, 17 | '0', 18 | )}:00.000`, 19 | eventTimezoneName, 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "skipLibCheck": true, 5 | "esModuleInterop": true, 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "noFallthroughCasesInSwitch": true, 15 | "baseUrl": "src", 16 | "paths": { 17 | "app/*": ["*"] 18 | } 19 | }, 20 | "include": ["src", "vite.config.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /adr/003-use-of-typescript.md: -------------------------------------------------------------------------------- 1 | # ADR 003: use of TypeScript 2 | 3 | This project is developed using [TypeScript](https://www.typescriptlang.org/) (a 4 | typed superset of JavaScript). 5 | 6 | JavaScript is the most popular language according to the 7 | [2019 Stack Overflow survey](https://insights.stackoverflow.com/survey/2019#technology) 8 | and it is therefore likely that many developers using the project will be 9 | familiar with the language concepts and how to use and run it. 10 | 11 | Virtually all cloud providers provide their SDKs in JavaScript or TypeScript 12 | which this project can integrate natively. 13 | -------------------------------------------------------------------------------- /src/Footer.module.css: -------------------------------------------------------------------------------- 1 | .Footer { 2 | padding: 1rem 2rem; 3 | opacity: 0.75; 4 | color: var(--color-text); 5 | } 6 | @media (min-width: 900px) { 7 | .Footer { 8 | padding: 4rem; 9 | } 10 | } 11 | .Footer a { 12 | color: var(--color-text); 13 | } 14 | .Footer p { 15 | line-height: 1.5rem; 16 | } 17 | 18 | .Copyright { 19 | max-width: 900px; 20 | margin: auto; 21 | font-size: 80%; 22 | opacity: 0.8; 23 | text-align: center; 24 | margin-top: 2rem; 25 | } 26 | @media (min-width: 900px) { 27 | .Copyright { 28 | margin-top: 4rem; 29 | } 30 | } 31 | 32 | .Nav a { 33 | margin: 0 0.5rem; 34 | } 35 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { App } from 'app/App' 2 | import { log } from 'app/log' 3 | import { StrictMode } from 'react' 4 | import { createRoot } from 'react-dom/client' 5 | import 'app/sentry.js' 6 | 7 | const l = (...args: any) => log('App', ...args) 8 | l('Version:', import.meta.env.PUBLIC_VERSION) 9 | l('Source code:', import.meta.env.PUBLIC_HOMEPAGE) 10 | 11 | const container = document.getElementById('root') 12 | if (container !== null) { 13 | const root = createRoot(container) // createRoot(container!) if you use TypeScript 14 | root.render( 15 | 16 | 17 | , 18 | ) 19 | } else { 20 | console.error('Could not find root element!') 21 | } 22 | -------------------------------------------------------------------------------- /src/App.module.css: -------------------------------------------------------------------------------- 1 | .Info { 2 | text-align: center; 3 | font-size: 14px; 4 | margin: 1rem; 5 | display: flex; 6 | justify-content: center; 7 | flex-direction: column; 8 | } 9 | .Info a { 10 | color: var(--color-text); 11 | } 12 | 13 | .Headline { 14 | font-size: 16px; 15 | } 16 | @media (min-width: 900px) { 17 | .Headline { 18 | font-size: 22px; 19 | } 20 | } 21 | 22 | .Title { 23 | display: flex; 24 | justify-content: space-between; 25 | align-items: center; 26 | padding: 0.25rem; 27 | } 28 | @media (min-width: 900px) { 29 | .Title { 30 | padding: 1rem; 31 | font-size: 16px; 32 | } 33 | } 34 | 35 | .Title h1 { 36 | margin: 0; 37 | } 38 | .Title div:global(.actions) { 39 | display: flex; 40 | } 41 | -------------------------------------------------------------------------------- /src/DaySelector.tsx: -------------------------------------------------------------------------------- 1 | import styles from 'app/DaySelector.module.css' 2 | import { format } from 'date-fns' 3 | import { useState } from 'react' 4 | export const DaySelector = ({ 5 | day, 6 | onUpdate, 7 | }: { 8 | day: string 9 | onUpdate: (date: string) => unknown 10 | }) => { 11 | const [value, setValue] = useState(day) 12 | return ( 13 | { 20 | setValue(value) 21 | try { 22 | onUpdate(format(new Date(value), 'yyyy-MM-dd')) 23 | } catch { 24 | // Ignore invalid dates here 25 | } 26 | }} 27 | /> 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { GithubIcon } from 'app/FeatherIcons' 2 | import styles from 'app/Footer.module.css' 3 | 4 | export const Footer = () => ( 5 | 30 | ) 31 | -------------------------------------------------------------------------------- /public/theme.css: -------------------------------------------------------------------------------- 1 | html[data-theme="dark"] { 2 | --color-background: #000; 3 | --color-text: #fff; 4 | --color-delete: #ff5235; 5 | --color-add: #0fa; 6 | --color-addDisabled: #aaa; 7 | --color-countdownWarning: #ff5e007a; 8 | --color-borderColor: #7d7d7d; 9 | --filter-calendar-picker-indicator: invert(1); 10 | --color-ongoing: #007500; 11 | } 12 | 13 | html[data-theme="light"] { 14 | --color-background: #fffbf6; 15 | --color-text: #000000cc; 16 | --color-add: #22a00d; 17 | --color-addDisabled: #aaa; 18 | --color-delete: #dc2000; 19 | --color-countdownWarning: #ff000052; 20 | --color-borderColor: #a7a7a7; 21 | --color-ongoing: #7cff7c; 22 | } 23 | 24 | body { 25 | background-color: var(--color-background); 26 | color: var(--color-text); 27 | font-family: "DM Mono", monospace; 28 | } 29 | -------------------------------------------------------------------------------- /src/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react' 2 | import { BrowserTracing } from '@sentry/browser' 3 | 4 | const enableSentry = import.meta.env.PUBLIC_SENTRY_DSN !== undefined 5 | 6 | if (enableSentry) { 7 | console.debug(`Sentry enabled.`) 8 | Sentry.init({ 9 | dsn: import.meta.env.PUBLIC_SENTRY_DSN, 10 | integrations: [new BrowserTracing()], 11 | tracesSampleRate: 0.05, 12 | beforeSend: (event) => { 13 | if (event.contexts?.device?.name?.includes('HeadlessChrome') ?? false) 14 | return null 15 | return event 16 | }, 17 | }) 18 | Sentry.setContext('app', { 19 | version: import.meta.env.PUBLIC_VERSION, 20 | }) 21 | } else { 22 | console.debug(`Sentry disabled.`) 23 | } 24 | 25 | export const captureMessage = (message: string): void => { 26 | console.debug(message) 27 | if (!enableSentry) return 28 | Sentry.captureMessage(message) 29 | } 30 | -------------------------------------------------------------------------------- /e2e-tests/browserWithFixedTime.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, chromium } from '@playwright/test' 2 | import path from 'path' 3 | 4 | export const browserWithFixedTime = async ( 5 | time?: Date, 6 | ): Promise => { 7 | const browser = await chromium.launch() 8 | const context = await browser.newContext({ 9 | locale: 'no-NO', 10 | timezoneId: 'Europe/Berlin', 11 | }) 12 | // See https://github.com/microsoft/playwright/issues/6347#issuecomment-965887758 13 | await context.addInitScript({ 14 | path: path.join(process.cwd(), 'node_modules/sinon/pkg/sinon.js'), 15 | }) 16 | // Auto-enable sinon right away 17 | // and enforce our "current" date 18 | await context.addInitScript(` 19 | const clock = sinon.useFakeTimers() 20 | clock.setSystemTime(${(time ?? new Date('2022-03-11T12:00:00Z')).getTime()}); 21 | window.__clock = clock 22 | `) 23 | 24 | return context 25 | } 26 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const ref: string 3 | export default ref 4 | } 5 | declare module '*.png' { 6 | const ref: string 7 | export default ref 8 | } 9 | 10 | declare module '*.css' 11 | 12 | interface ImportMeta { 13 | hot: { 14 | accept: Function 15 | dispose: Function 16 | } 17 | env: { 18 | // Vite built-in 19 | MODE: string 20 | BASE_URL?: string 21 | // Custom 22 | PUBLIC_VERSION: string 23 | PUBLIC_HOMEPAGE: string 24 | PUBLIC_ISSUES: string 25 | PUBLIC_MANIFEST_NAME: string 26 | PUBLIC_MANIFEST_SHORT_NAME: string 27 | PUBLIC_MANIFEST_THEME_COLOR: string 28 | PUBLIC_MANIFEST_BACKGROUND_COLOR: string 29 | PUBLIC_SENTRY_DSN: string 30 | } 31 | } 32 | 33 | type Sessions = Record 34 | 35 | type Schedule = { 36 | name: string 37 | day: string 38 | tz: string 39 | sessions: Sessions 40 | hidePastSessions: boolean 41 | } 42 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test' 2 | import { devices } from '@playwright/test' 3 | 4 | /** 5 | * See https://playwright.dev/docs/test-configuration. 6 | */ 7 | const config: PlaywrightTestConfig = { 8 | testDir: './e2e-tests', 9 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 10 | forbidOnly: !!process.env.CI, 11 | /* Opt out of parallel tests on CI. */ 12 | workers: process.env.CI ? 1 : undefined, 13 | use: { 14 | trace: 'on-first-retry', 15 | video: 'on-first-retry', 16 | screenshot: 'only-on-failure', 17 | }, 18 | projects: [ 19 | { 20 | name: 'chromium', 21 | use: { 22 | ...devices['Desktop Chrome'], 23 | }, 24 | }, 25 | ], 26 | webServer: { 27 | command: 'npm start', 28 | url: 'http://localhost:8080/', 29 | timeout: 10 * 1000, 30 | reuseExistingServer: process.env.CI === undefined, 31 | }, 32 | } 33 | 34 | export default config 35 | -------------------------------------------------------------------------------- /src/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { DarkModeIcon, LightModeIcon } from 'app/FeatherIcons' 2 | import formStyles from 'app/Form.module.css' 3 | 4 | export enum Theme { 5 | light = 'light', 6 | dark = 'dark', 7 | } 8 | 9 | export const ThemeSwitcher = ({ 10 | currentTheme, 11 | onSwitch, 12 | }: { 13 | currentTheme: Theme 14 | onSwitch: (theme: Theme) => void 15 | }) => ( 16 | <> 17 | {currentTheme === Theme.dark && ( 18 | 28 | )} 29 | {currentTheme === Theme.light && ( 30 | 40 | )} 41 | 42 | ) 43 | -------------------------------------------------------------------------------- /adr/001-use-saga-as-the-main-branch.md: -------------------------------------------------------------------------------- 1 | # ADR 001: Use saga as the name for the main branch 2 | 3 | Historically, Git and other software use terms such as `master/slave`, 4 | `whitelist/blacklist`, which are based on racial concepts. Their continued use 5 | maintains the racial stereotypes they depict. Better alternatives in meaning and 6 | technical correctness exist, like `leader/follower`, `blocklist/allowlist`. 7 | 8 | In the Nordic mythology, a `saga` is a long, continuous recollection of stories 9 | about the history of humans, legends, and Gods. The term `saga` reflects very 10 | well what happens in a Git repository. Changes happen in branches (some teams 11 | tie them to _User Stories_, which are sometimes directly or loosely coupled to 12 | the main branch). Once the changes are finalized, they get added to the main 13 | branch, or get appended in the case of a rebase. The mental model of a big book 14 | of stories fits this process very well. 15 | 16 | Therefore, the main branch in this project is named `saga`. `master` must not be 17 | used. 18 | -------------------------------------------------------------------------------- /src/SessionName.tsx: -------------------------------------------------------------------------------- 1 | const sessionLinkRegEx = 2 | /^(.+)\|(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))$/ 3 | 4 | export const toURL = (url: string): URL | undefined => { 5 | try { 6 | return new URL(url) 7 | } catch { 8 | console.error(`Invalid URL: ${url}`) 9 | return undefined 10 | } 11 | } 12 | 13 | export const formatSessionName = ( 14 | name: string, 15 | ): { sessionName: string; url?: URL } => { 16 | let sessionName = name 17 | let url: URL | undefined = undefined 18 | const matches = sessionLinkRegEx.exec(name) 19 | if (matches !== null) { 20 | sessionName = matches[1] 21 | url = toURL(matches[2]) 22 | } 23 | return { 24 | sessionName, 25 | url, 26 | } 27 | } 28 | 29 | export const SessionName = ({ name }: { name: string }) => { 30 | const { sessionName, url } = formatSessionName(name) 31 | if (url !== undefined) 32 | return ( 33 | 34 | {sessionName} 35 | 36 | ) 37 | return <>{sessionName} 38 | } 39 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{ shortName }} 16 | 21 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/time.ts: -------------------------------------------------------------------------------- 1 | import { zonedTimeToUtc, format } from 'date-fns-tz' 2 | 3 | export const toUserTime = 4 | ({ 5 | conferenceDate, 6 | eventTimezoneName, 7 | }: { 8 | conferenceDate: string 9 | eventTimezoneName: string 10 | }) => 11 | (time: number): Date => { 12 | const minutes = time % 100 13 | const hours = (time - minutes) / 100 14 | return zonedTimeToUtc( 15 | `${conferenceDate} ${`${hours}`.padStart(2, '0')}:${`${minutes}`.padStart( 16 | 2, 17 | '0', 18 | )}:00.000`, 19 | eventTimezoneName, 20 | ) 21 | } 22 | export const formatUserTime = 23 | ({ userTimeZone }: { userTimeZone: string }) => 24 | (date: Date): string => 25 | format(date, 'HH:mm', { timeZone: userTimeZone }) 26 | 27 | export const toEventTime = 28 | ({ 29 | conferenceDate, 30 | userTimeZone, 31 | }: { 32 | conferenceDate: string 33 | userTimeZone: string 34 | }) => 35 | (time: number): Date => { 36 | const minutes = time % 100 37 | const hours = (time - minutes) / 100 38 | return zonedTimeToUtc( 39 | `${conferenceDate} ${`${hours}`.padStart(2, '0')}:${`${minutes}`.padStart( 40 | 2, 41 | '0', 42 | )}:00.000`, 43 | userTimeZone, 44 | ) 45 | } 46 | export const formatEventTime = (date: Date): string => format(date, 'HH:mm') 47 | -------------------------------------------------------------------------------- /e2e-tests/highlight-ongoing-session.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { browserWithFixedTime } from './browserWithFixedTime.js' 3 | 4 | test.describe('Highlight ongoing session', () => { 5 | test('the currently ongoing session should be highlighted', async () => { 6 | const context = await browserWithFixedTime() 7 | const page = await context.newPage() 8 | await page.goto('http://localhost:8080/') 9 | await expect( 10 | page.locator('td:has-text("Lunch Break") >> xpath=ancestor::tr'), 11 | ).toHaveClass('ongoing') 12 | // There should only be one highlighted row 13 | await expect(page.locator('tr.ongoing')).toHaveCount(1) 14 | }) 15 | test('highlight parallel sessions', async () => { 16 | const context = await browserWithFixedTime(new Date('2022-03-11T15:00:00Z')) 17 | const page = await context.newPage() 18 | await page.goto('http://localhost:8080/') 19 | await expect( 20 | page.locator('td:has-text("Session 4") >> xpath=ancestor::tr'), 21 | ).toHaveClass('ongoing') 22 | await expect( 23 | page.locator( 24 | 'td:has-text("You can have sessions at the same time, too!") >> xpath=ancestor::tr', 25 | ), 26 | ).toHaveClass('ongoing') 27 | await expect(page.locator('tr.ongoing')).toHaveCount(2) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/Form.module.css: -------------------------------------------------------------------------------- 1 | .Button { 2 | background-color: transparent; 3 | color: var(--color-text); 4 | opacity: 0.75; 5 | padding: 0; 6 | border: 0; 7 | display: flex; 8 | align-items: center; 9 | margin-right: 0.5rem; 10 | } 11 | 12 | .DeleteButton { 13 | composes: Button; 14 | color: var(--color-delete); 15 | } 16 | 17 | .AddButton { 18 | composes: Button; 19 | color: var(--color-add); 20 | } 21 | .AddButton:disabled { 22 | color: var(--color-addDisabled); 23 | } 24 | 25 | .Input { 26 | background-color: transparent; 27 | border: 1px solid var(--color-text); 28 | padding: 0.25rem 0.5rem; 29 | height: 30px; 30 | color: var(--color-text); 31 | } 32 | 33 | .NumberInput { 34 | composes: Input; 35 | width: 30px; 36 | } 37 | @media (min-width: 600px) { 38 | .NumberInput { 39 | width: 60px; 40 | } 41 | } 42 | 43 | .TextInput { 44 | composes: Input; 45 | width: calc(100% - 1rem); 46 | } 47 | 48 | .DateEditor { 49 | display: flex; 50 | flex-direction: column; 51 | align-items: center; 52 | } 53 | 54 | @media (min-width: 900px) { 55 | .DateEditor { 56 | flex-direction: row; 57 | } 58 | } 59 | 60 | .Form label { 61 | width: 100%; 62 | display: block; 63 | margin-bottom: 0.5rem; 64 | } 65 | .Form fieldset { 66 | border: 0; 67 | margin: 0; 68 | padding: 0; 69 | } 70 | .Form fieldset + fieldset { 71 | margin-top: 1rem; 72 | } 73 | -------------------------------------------------------------------------------- /src/Table.module.css: -------------------------------------------------------------------------------- 1 | .Table { 2 | border-collapse: collapse; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | .Table td, 7 | .Table th { 8 | border: 1px solid var(--color-borderColor); 9 | padding: 0.25rem; 10 | vertical-align: middle; 11 | } 12 | 13 | @media (min-width: 900px) { 14 | .Table td, 15 | .Table th { 16 | padding: 1rem; 17 | font-size: 16px; 18 | } 19 | } 20 | 21 | .Table td:first-child, 22 | .Table th:first-child { 23 | border-left: 0; 24 | } 25 | .Table td:last-child, 26 | .Table th:last-child { 27 | border-right: 0; 28 | } 29 | .Table td { 30 | font-size: 14px; 31 | } 32 | @media (min-width: 900px) { 33 | .Table td { 34 | font-size: 16px; 35 | } 36 | } 37 | .Table th { 38 | font-size: 12px; 39 | } 40 | @media (min-width: 900px) { 41 | .Table th { 42 | font-size: 14px; 43 | } 44 | } 45 | .Table td:global(.time) { 46 | text-align: right; 47 | width: 10%; 48 | white-space: nowrap; 49 | } 50 | .Table td:global(.time) legend { 51 | text-align: left; 52 | margin-bottom: 0.5rem; 53 | } 54 | .Table td:global(.hot) { 55 | background-color: var(--color-countdownWarning); 56 | } 57 | .Table th small { 58 | opacity: 0.6; 59 | } 60 | 61 | .Table td small { 62 | opacity: 0.6; 63 | font-weight: bold; 64 | } 65 | .Table a { 66 | color: var(--color-text); 67 | } 68 | 69 | .Table tr:global(.ongoing) { 70 | background-color: var(--color-ongoing); 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/test-and-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: push 4 | 5 | env: 6 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 7 | 8 | jobs: 9 | main: 10 | runs-on: ubuntu-latest 11 | 12 | environment: production 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: "20.x" 20 | 21 | - name: Keep npm cache around to speed up installs 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/.npm 25 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 26 | 27 | - name: Install dependencies 28 | run: npm ci --no-audit 29 | 30 | - name: Compile TypeScript 31 | run: npx tsc 32 | 33 | - name: Lint 34 | run: npm run lint 35 | 36 | - name: Run unit tests 37 | run: npm test 38 | 39 | - name: Build 40 | env: 41 | PUBLIC_VERSION: ${{ github.sha }} 42 | PUBLIC_SENTRY_DSN: ${{ vars.SENTRY_DSN }} 43 | run: npm run build 44 | 45 | - name: Install playwright 46 | run: npx playwright install 47 | 48 | - name: Run end-to-end tests 49 | run: npm run test:e2e 50 | 51 | - uses: actions/upload-artifact@v4 52 | if: always() 53 | with: 54 | name: ${{ env.GITHUB_REPOSITORY }}-${{ github.sha }} 55 | path: build 56 | 57 | - name: Semantic release 58 | continue-on-error: true 59 | run: npx semantic-release 60 | -------------------------------------------------------------------------------- /src/DaySelector.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react' 2 | import { DaySelector } from 'app/DaySelector' 3 | 4 | describe('DaySelector', () => { 5 | it('should update with a correct date', () => { 6 | let updatedDay = '' 7 | const { getByLabelText } = render( 8 | { 11 | updatedDay = newDay 12 | }} 13 | />, 14 | ) 15 | const input = getByLabelText('date-input') 16 | fireEvent.change(input, { 17 | target: { value: '2021-02-28' }, 18 | }) 19 | expect(updatedDay).toBe('2021-02-28') 20 | expect((input as HTMLInputElement).value).toBe('2021-02-28') 21 | }) 22 | it('should not update with an incorret/partial date', () => { 23 | let updatedDay: string | undefined = undefined 24 | const { getByLabelText } = render( 25 | { 28 | updatedDay = newDay 29 | }} 30 | />, 31 | ) 32 | const input = getByLabelText('date-input') 33 | // it should not update on invalid date 34 | fireEvent.change(input, { 35 | target: { value: '2021-0-28' }, 36 | }) 37 | // This invalid browser behaviour can't be tested 38 | // In Safari this would work: 39 | // expect((input as HTMLInputElement).value).toBe('2021-0-28') // should however show invalid input 40 | expect(updatedDay).toBe(undefined) 41 | // it should update with valid date' 42 | fireEvent.change(input, { 43 | target: { value: '2021-03-28' }, 44 | }) 45 | expect(updatedDay).toBe('2021-03-28') 46 | expect((input as HTMLInputElement).value).toBe('2021-03-28') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Local Schedule 2 | 3 | ![Build and Release](https://github.com/coderbyheart/localschedule/workflows/Build%20and%20Release/badge.svg?branch=saga) 4 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 5 | [![@commitlint/config-conventional](https://img.shields.io/badge/%40commitlint-config--conventional-brightgreen)](https://github.com/conventional-changelog/commitlint/tree/master/@commitlint/config-conventional) 6 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier/) 7 | [![ESLint: TypeScript](https://img.shields.io/badge/ESLint-TypeScript-blue.svg)](https://github.com/typescript-eslint/typescript-eslint) 8 | [![React](https://github.com/aleen42/badges/raw/master/src/react.svg)](https://reactjs.org/) 9 | [![CSS modules](https://img.shields.io/badge/CSS-modules-yellow)](https://github.com/css-modules/css-modules) 10 | [![Vite](https://github.com/aleen42/badges/raw/master/src/vitejs.svg)](https://vitejs.dev/) 11 | 12 | Create a shareable schedule with times in your local timezone. Great for remote 13 | conferences! 14 | 15 | ### Install dependencies 16 | 17 | npm ci 18 | 19 | ### Start the development server 20 | 21 | npm start 22 | 23 | ### Run the tests 24 | 25 | Unit tests can be run using 26 | 27 | npm test 28 | 29 | End-to-end tests can be run using 30 | 31 | npm run test:e2e 32 | 33 | You can see the browser by running 34 | 35 | PWDEBUG=1 npm run test:e2e 36 | 37 | ## Architecture decision records (ADRs) 38 | 39 | see [./adr](./adr). 40 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import fs from 'fs' 3 | import Handlebars from 'handlebars' 4 | import path from 'path' 5 | import { defineConfig } from 'vite' 6 | 7 | const { 8 | version, 9 | homepage, 10 | bugs: { url: issuesUrl }, 11 | } = JSON.parse( 12 | fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'), 13 | ) 14 | 15 | const { short_name, name, theme_color, background_color } = JSON.parse( 16 | fs.readFileSync(path.join(process.cwd(), 'public', 'manifest.json'), 'utf-8'), 17 | ) 18 | 19 | process.env.PUBLIC_VERSION = process.env.PUBLIC_VERSION ?? version ?? Date.now() 20 | process.env.PUBLIC_HOMEPAGE = process.env.PUBLIC_HOMEPAGE ?? homepage 21 | process.env.PUBLIC_ISSUES = issuesUrl 22 | process.env.PUBLIC_MANIFEST_SHORT_NAME = short_name 23 | process.env.PUBLIC_MANIFEST_NAME = name 24 | process.env.PUBLIC_MANIFEST_THEME_COLOR = theme_color 25 | process.env.PUBLIC_MANIFEST_BACKGROUND_COLOR = background_color 26 | 27 | const replaceInIndex = (data: Record) => ({ 28 | name: 'replace-in-index', 29 | transformIndexHtml: (source: string): string => { 30 | const template = Handlebars.compile(source) 31 | return template(data) 32 | }, 33 | }) 34 | 35 | // https://vitejs.dev/config/ 36 | export default defineConfig({ 37 | plugins: [ 38 | react(), 39 | replaceInIndex({ 40 | name, 41 | shortName: short_name, 42 | themeColor: theme_color, 43 | version, 44 | }), 45 | ], 46 | base: `${(process.env.BASE_URL ?? '').replace(/\/+$/, '')}/`, 47 | preview: { 48 | host: 'localhost', 49 | port: 8080, 50 | }, 51 | server: { 52 | host: 'localhost', 53 | port: 8080, 54 | }, 55 | resolve: { 56 | alias: [{ find: 'app/', replacement: '/src/' }], 57 | }, 58 | build: { 59 | outDir: './build', 60 | }, 61 | envPrefix: 'PUBLIC_', 62 | }) 63 | -------------------------------------------------------------------------------- /src/ongoingSessions.ts: -------------------------------------------------------------------------------- 1 | import { toUTCTime } from 'app/toUTCTime' 2 | 3 | export type ConfDate = Pick 4 | export type ConfDateWithSessions = Pick & ConfDate 5 | 6 | const timeWithTrackToUTC = (timeWithTrack: string, schedule: ConfDate): Date => 7 | toUTCTime({ 8 | conferenceDate: schedule.day, 9 | eventTimezoneName: schedule.tz, 10 | })(parseInt(timeWithTrack.split('@')[0], 10)) 11 | 12 | const isBeforeNow = ( 13 | [timeWithTrack]: [string, string], 14 | now: Date, 15 | schedule: ConfDate, 16 | ): boolean => { 17 | const time = timeWithTrackToUTC(timeWithTrack, schedule) 18 | return time.getTime() < now.getTime() 19 | } 20 | 21 | export const ongoingSessions = ( 22 | schedule: ConfDateWithSessions, 23 | now = new Date(), 24 | ): Sessions => { 25 | return ( 26 | Object.entries(schedule.sessions) 27 | .sort( 28 | ([timeWithTrackA], [timeWithTrackB]) => 29 | parseInt(timeWithTrackA.split('@')[0]) - 30 | parseInt(timeWithTrackB.split('@')[0]), 31 | ) 32 | // Filter out future sessions 33 | .filter( 34 | ([timeWithTrack]) => 35 | timeWithTrackToUTC(timeWithTrack, schedule).getTime() <= 36 | now.getTime(), 37 | ) 38 | // Check of next is ongoing 39 | .filter(([timeWithTrack], i, sessions) => { 40 | const time = timeWithTrackToUTC(timeWithTrack, schedule) 41 | // Find the next session that does not start at the same time 42 | const next = sessions.find( 43 | ([nextTimeWithTrack], j) => 44 | j > i && 45 | timeWithTrackToUTC(nextTimeWithTrack, schedule).getTime() > 46 | time.getTime(), 47 | ) 48 | if (next === undefined) return true // No next session found, so this one must be the last session and therefore it is ongoing 49 | return !isBeforeNow(next, now, schedule) 50 | }) 51 | .reduce((ongoing, [k, v]) => ({ ...ongoing, [k]: v }), {}) 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/ongoingSessions.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfDateWithSessions, ongoingSessions } from 'app/ongoingSessions' 2 | import { format } from 'date-fns' 3 | 4 | const makeSchedule = (now: Date): ConfDateWithSessions => ({ 5 | day: format(now, 'yyyy-MM-dd'), 6 | tz: 'Europe/Oslo', 7 | sessions: { 8 | 900: 'Arrival & Breakfast', 9 | 930: 'Opening & Marketplace', 10 | 1030: 'Session 1', 11 | 1130: 'Coffee Break', 12 | 1145: 'Session 2', 13 | 1245: 'Lunch Break', 14 | 1430: 'Session 3', 15 | 1530: 'Coffee Break', 16 | 1545: 'Session 4', 17 | '1545@Main hall': `You can have sessions at the same time, too!`, 18 | 1645: 'Coffee Break', 19 | 1700: 'Closing & Retro', 20 | 1730: 'Dinner Break', 21 | 1900: 'Evening Activities', 22 | }, 23 | }) 24 | 25 | describe('ongoingSessions()', () => { 26 | it('should mark one ongoing session', () => { 27 | const now = new Date('2022-03-11T13:00:00+01:00') 28 | expect(ongoingSessions(makeSchedule(now), now)).toEqual({ 29 | 1245: 'Lunch Break', 30 | }) 31 | }) 32 | 33 | it('should mark to parallel ongoing sessions', () => { 34 | const now = new Date('2022-03-11T16:00:00+01:00') 35 | expect(ongoingSessions(makeSchedule(now), now)).toEqual({ 36 | 1545: 'Session 4', 37 | '1545@Main hall': `You can have sessions at the same time, too!`, 38 | }) 39 | }) 40 | 41 | it('should not highlight the last session on the next day', () => { 42 | const now = new Date('2022-03-12T08:00:00+01:00') 43 | expect(ongoingSessions(makeSchedule(now), now)).toEqual({}) 44 | }) 45 | 46 | it('should not mark the first session before begin', () => { 47 | const now = new Date('2022-03-11T08:59:59.999+01:00') 48 | expect(ongoingSessions(makeSchedule(now), now)).toEqual({}) 49 | }) 50 | 51 | it('should mark sessions exactly when they start', () => { 52 | const now = new Date('2022-03-11T09:00:00+01:00') 53 | expect(ongoingSessions(makeSchedule(now), now)).toEqual({ 54 | 900: 'Arrival & Breakfast', 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /e2e-tests/session-in-local-time.spec.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, expect, test } from '@playwright/test' 2 | import { browserWithFixedTime } from './browserWithFixedTime.js' 3 | 4 | test.describe('Adding an item', () => { 5 | let context: BrowserContext 6 | test.beforeAll(async () => { 7 | context = await browserWithFixedTime() 8 | }) 9 | 10 | test('should allow me to add todo items', async () => { 11 | const page = await context.newPage() 12 | await page.goto('http://localhost:8080/') 13 | 14 | // Create a new entry 15 | await page.locator('button[name="edit-schedule"]').click() 16 | await page.locator('input[name="conference-day"]').fill('2022-03-12') 17 | await page 18 | .locator('select[name="conference-timezone"]') 19 | .selectOption('Asia/Tokyo') 20 | await page.locator('input[name="session-hour"]').fill('9') 21 | await page.locator('input[name="session-minute"]').fill('45') 22 | 23 | const sessionName = 'Breakfast for Champions' 24 | const sessionNameInput = page.locator('input[name="session-name"]') 25 | await sessionNameInput.fill(sessionName) 26 | await sessionNameInput.press('Enter') 27 | 28 | await page.locator('button[name="save-schedule"]').click() 29 | 30 | // Table should have the new entry 31 | await expect(page.locator('table tbody')).toContainText(sessionName) 32 | 33 | // Find the row of the new entry 34 | const tr = page 35 | .locator(`table tbody td:has-text("${sessionName}")`) // td 36 | .locator(`xpath=ancestor::tr`) 37 | const conferenceTime = tr.locator('td:first-child') 38 | await expect(conferenceTime).toContainText('09:45') 39 | 40 | // Time Zone difference between Berlin and Tokyo on 2022-03-12 is 8 hours 41 | 42 | const localTime = tr.locator('td:nth-child(2)') 43 | await expect(localTime).toContainText('01:45') 44 | }) 45 | 46 | test('should display the users time zone', async () => { 47 | const page = await context.newPage() 48 | await page.goto('http://localhost:8080/') 49 | 50 | await expect( 51 | page.locator('table thead th:nth-child(2) small'), 52 | ).toContainText('Berlin') 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier", 10 | "plugin:jsx-a11y/recommended" 11 | ], 12 | "plugins": [ 13 | "jsx-a11y", 14 | "@typescript-eslint", 15 | "unicorn", 16 | "prefer-arrow", 17 | "import" 18 | ], 19 | "rules": { 20 | "no-restricted-imports": [ 21 | "error", 22 | { 23 | "patterns": [ 24 | { 25 | "group": [".*"], 26 | "message": "Usage of relative imports is not allowed. Use aliased imports." 27 | } 28 | ] 29 | } 30 | ], 31 | "@typescript-eslint/indent": ["off"], 32 | "@typescript-eslint/no-object-literal-type-assertion": ["off"], 33 | "@typescript-eslint/explicit-function-return-type": ["off"], 34 | "@typescript-eslint/await-thenable": ["error"], 35 | "@typescript-eslint/no-extraneous-class": ["error"], 36 | "@typescript-eslint/no-floating-promises": ["error"], 37 | "@typescript-eslint/no-for-in-array": ["error"], 38 | "@typescript-eslint/no-require-imports": ["error"], 39 | "@typescript-eslint/no-this-alias": ["error"], 40 | "@typescript-eslint/no-type-alias": ["off"], 41 | "@typescript-eslint/no-unnecessary-type-assertion": ["error"], 42 | "@typescript-eslint/no-useless-constructor": ["error"], 43 | "@typescript-eslint/prefer-for-of": ["error"], 44 | "@typescript-eslint/prefer-function-type": ["error"], 45 | "@typescript-eslint/prefer-includes": ["error"], 46 | "@typescript-eslint/prefer-readonly": ["error"], 47 | "@typescript-eslint/prefer-regexp-exec": ["error"], 48 | "@typescript-eslint/prefer-string-starts-ends-with": ["error"], 49 | "@typescript-eslint/promise-function-async": ["error"], 50 | "@typescript-eslint/require-array-sort-compare": ["error"], 51 | "@typescript-eslint/restrict-plus-operands": ["error"], 52 | "semi": "off", 53 | "@typescript-eslint/semi": ["off"], 54 | "@typescript-eslint/member-delimiter-style": [ 55 | "error", 56 | { 57 | "multiline": { 58 | "delimiter": "none", 59 | "requireLast": false 60 | }, 61 | "singleline": { 62 | "delimiter": "semi", 63 | "requireLast": false 64 | } 65 | } 66 | ], 67 | "@typescript-eslint/prefer-interface": ["off"], 68 | "@typescript-eslint/no-explicit-any": ["off"], 69 | "no-console": ["off"], 70 | "@typescript-eslint/strict-boolean-expressions": ["error"], 71 | "@typescript-eslint/prefer-nullish-coalescing": ["error"], 72 | "@typescript-eslint/prefer-optional-chain": ["error"], 73 | "prefer-promise-reject-errors": ["error"], 74 | "unicorn/prefer-string-slice": ["error"], 75 | "@typescript-eslint/switch-exhaustiveness-check": ["error"], 76 | "prefer-arrow/prefer-arrow-functions": ["error"], 77 | "object-shorthand": ["error"], 78 | "import/extensions": ["error", "always", { "ignorePackages": true }] 79 | }, 80 | "overrides": [ 81 | { 82 | "files": ["*.d.ts"], 83 | "rules": { 84 | "@typescript-eslint/ban-types": "off" 85 | } 86 | } 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /src/useIcalExport.ts: -------------------------------------------------------------------------------- 1 | import { formatSessionName } from 'app/SessionName' 2 | import { toUTCTime } from 'app/toUTCTime' 3 | import { createEvents } from 'ics' 4 | 5 | export const useIcalExport = (schedule: Schedule) => { 6 | return (): void => { 7 | const utcTime = toUTCTime({ 8 | conferenceDate: schedule.day, 9 | eventTimezoneName: schedule.tz, 10 | }) 11 | const { error, value } = createEvents( 12 | Object.entries(schedule.sessions) 13 | .sort( 14 | ([timeWithTrackA], [timeWithTrackB]) => 15 | parseInt(timeWithTrackA.split('@')[0]) - 16 | parseInt(timeWithTrackB.split('@')[0]), 17 | ) 18 | .map(([timeWithTrack, name], i, sessions) => { 19 | const { sessionName, url } = formatSessionName(name) 20 | const urlText = url === undefined ? undefined : url.toString() 21 | 22 | const [time, track] = timeWithTrack.split('@') 23 | const startTime = utcTime(parseInt(time, 10)) 24 | 25 | // Find next entry for end time 26 | let next: [string, string] | undefined = undefined 27 | let nextStartTime: Date | undefined = undefined 28 | 29 | if (track === undefined) { 30 | // This session has no track. Find next entry in all tracks. This entry is probably a Lunch break that is valid for all tracks. 31 | let j = i 32 | while ( 33 | j < sessions.length - 1 && 34 | (nextStartTime?.getTime() ?? 0) <= startTime.getTime() 35 | ) { 36 | next = sessions[++j] 37 | const [nextStartTimeString] = next[0].split('@') 38 | nextStartTime = utcTime(parseInt(nextStartTimeString, 10)) 39 | } 40 | } else { 41 | let nextTrack: string | undefined = undefined 42 | // This session a track. Find next entry in the same track OR a without a track (e.g. a lunch break) 43 | let j = i 44 | while ( 45 | j < sessions.length - 1 && 46 | (nextStartTime?.getTime() ?? 0) <= startTime.getTime() && 47 | (nextTrack !== track || nextTrack === undefined) 48 | ) { 49 | next = sessions[++j] 50 | const [nextStartTimeString, nextTrackString] = next[0].split('@') 51 | nextTrack = nextTrackString 52 | nextStartTime = utcTime(parseInt(nextStartTimeString, 10)) 53 | } 54 | } 55 | 56 | const description = [ 57 | schedule.name, 58 | `Session: ${sessionName}`, 59 | urlText, 60 | ].join('\n') 61 | 62 | if (next !== undefined) { 63 | const [endTimeString] = next[0].split('@') 64 | const endTime = utcTime(parseInt(endTimeString, 10)) 65 | return { 66 | title: `${schedule.name}: ${sessionName}`, 67 | start: [ 68 | startTime.getUTCFullYear(), 69 | startTime.getUTCMonth() + 1, 70 | startTime.getUTCDate(), 71 | startTime.getUTCHours(), 72 | startTime.getUTCMinutes(), 73 | ], 74 | startInputType: 'utc', 75 | end: [ 76 | endTime.getUTCFullYear(), 77 | endTime.getUTCMonth() + 1, 78 | endTime.getUTCDate(), 79 | endTime.getUTCHours(), 80 | endTime.getUTCMinutes(), 81 | ], 82 | endInputType: 'utc', 83 | url: urlText, 84 | description, 85 | location: track, 86 | } 87 | } else { 88 | return { 89 | title: `${schedule.name}: ${sessionName}`, 90 | start: [ 91 | startTime.getUTCFullYear(), 92 | startTime.getUTCMonth() + 1, 93 | startTime.getUTCDate(), 94 | startTime.getUTCHours(), 95 | startTime.getUTCMinutes(), 96 | ], 97 | startInputType: 'utc', 98 | duration: { minutes: 60 }, 99 | url: urlText, 100 | description, 101 | location: track, 102 | } 103 | } 104 | }), 105 | ) 106 | 107 | if (value !== undefined) { 108 | const file = new File([value], `${schedule.name}.ics`) 109 | const link = document.createElement('a') 110 | link.style.display = 'none' 111 | link.href = URL.createObjectURL(file) 112 | link.download = file.name 113 | 114 | document.body.appendChild(link) 115 | link.click() 116 | 117 | setTimeout(() => { 118 | URL.revokeObjectURL(link.href) 119 | link.parentNode?.removeChild(link) 120 | }, 0) 121 | } 122 | 123 | if (error !== undefined && error !== null) 124 | console.error(`[iCalExport]`, error) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@coderbyheart/localschedule", 3 | "version": "0.0.0-development", 4 | "description": "Create a shareable schedule with times in your local timezone. Great for remote conferences!", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "vite build --emptyOutDir", 9 | "preview": "vite preview", 10 | "prepare": "husky", 11 | "test": "jest --passWithNoTests", 12 | "test:e2e": "npx tsc -p e2e-tests/tsconfig.json && npx playwright test -c e2e-tests-out", 13 | "postinstall": "check-node-version --package", 14 | "lint": "eslint --ext .js,.ts,.tsx src/*.{ts,tsx}" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/coderbyheart/localschedule.git" 19 | }, 20 | "author": "Markus Tacker | https://coderbyheart.com/", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/coderbyheart/localschedule/issues" 24 | }, 25 | "homepage": "https://github.com/coderbyheart/localschedule#readme", 26 | "keywords": [ 27 | "Schedule", 28 | "Timezones", 29 | "Conferences" 30 | ], 31 | "dependencies": { 32 | "@nordicsemiconductor/from-env": "3.0.1", 33 | "@sentry/browser": "7.120.4", 34 | "@sentry/react": "7.120.4", 35 | "ajv": "8.17.1", 36 | "date-fns-tz": "2.0.1", 37 | "feather-icons": "4.29.2", 38 | "ics": "3.8.1", 39 | "react": "18.3.1", 40 | "react-dom": "18.3.1", 41 | "react-router-dom": "6.30.2" 42 | }, 43 | "devDependencies": { 44 | "@bifravst/prettier-config": "1.1.13", 45 | "@commitlint/config-conventional": "19.8.1", 46 | "@nordicsemiconductor/eslint-config-asset-tracker-cloud-typescript": "17.0.0", 47 | "@nordicsemiconductor/object-to-env": "7.0.7", 48 | "@playwright/test": "1.56.1", 49 | "@swc/jest": "0.2.38", 50 | "@testing-library/react": "14.3.1", 51 | "@types/feather-icons": "4.29.4", 52 | "@types/jest": "29.5.14", 53 | "@types/react": "18.3.27", 54 | "@types/react-dom": "18.3.7", 55 | "@types/sinon": "17.0.4", 56 | "@typescript-eslint/eslint-plugin": "7.18.0", 57 | "@typescript-eslint/parser": "7.18.0", 58 | "@vitejs/plugin-react": "4.7.0", 59 | "check-node-version": "4.2.1", 60 | "eslint-config-prettier": "9.1.2", 61 | "eslint-plugin-import": "2.32.0", 62 | "eslint-plugin-jsx-a11y": "6.10.2", 63 | "eslint-plugin-no-restricted-imports": "0.0.0", 64 | "eslint-plugin-prefer-arrow": "1.2.3", 65 | "eslint-plugin-unicorn": "51.0.1", 66 | "handlebars": "4.7.8", 67 | "husky": "9.1.7", 68 | "identity-obj-proxy": "3.0.0", 69 | "jest": "29.7.0", 70 | "jest-environment-jsdom": "29.7.0", 71 | "sinon": "17.0.2", 72 | "vite": "5.4.21" 73 | }, 74 | "lint-staged": { 75 | "src/**/*.{ts,tsx}": [ 76 | "eslint --ext .js,.ts,.jsx,.tsx" 77 | ], 78 | "e2e-tests/**/*.{ts,tsx}": [ 79 | "eslint --ext .js,.ts,.jsx,.tsx --parser-options tsconfigRootDir:e2e-tests" 80 | ], 81 | "*.{md,json,yaml,yml,html}": [ 82 | "prettier --write" 83 | ] 84 | }, 85 | "engines": { 86 | "node": ">=20", 87 | "npm": ">=9" 88 | }, 89 | "release": { 90 | "branches": [ 91 | "saga" 92 | ], 93 | "remoteTags": true, 94 | "plugins": [ 95 | "@semantic-release/commit-analyzer", 96 | "@semantic-release/release-notes-generator", 97 | "@semantic-release/github" 98 | ] 99 | }, 100 | "prettier": "@bifravst/prettier-config", 101 | "jest": { 102 | "testRegex": ".+\\.spec\\.tsx?$", 103 | "moduleNameMapper": { 104 | "^.+\\.css$": "identity-obj-proxy", 105 | "^app/(.*)$": "/src/$1" 106 | }, 107 | "testPathIgnorePatterns": [ 108 | "/node_modules/", 109 | "/e2e-tests/" 110 | ], 111 | "transform": { 112 | "^.+\\.(t|j)sx?$": [ 113 | "@swc/jest", 114 | { 115 | "sourceMaps": true, 116 | "jsc": { 117 | "parser": { 118 | "syntax": "typescript", 119 | "tsx": true 120 | }, 121 | "transform": { 122 | "react": { 123 | "runtime": "automatic" 124 | } 125 | } 126 | } 127 | } 128 | ] 129 | }, 130 | "extensionsToTreatAsEsm": [ 131 | ".ts", 132 | ".tsx" 133 | ], 134 | "testEnvironment": "jsdom" 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Schedule.tsx: -------------------------------------------------------------------------------- 1 | import { ongoingSessions } from 'app/ongoingSessions' 2 | import { SessionName } from 'app/SessionName' 3 | import tableStyles from 'app/Table.module.css' 4 | import { 5 | formatEventTime, 6 | formatUserTime, 7 | toEventTime, 8 | toUserTime, 9 | } from 'app/time' 10 | import { differenceInCalendarDays, formatDistance } from 'date-fns' 11 | import { useEffect, useState } from 'react' 12 | 13 | const diff = (startTime: Date, conferenceDate: Date) => { 14 | const now = new Date() 15 | if (startTime.getTime() - Date.now() > 24 * 60 * 60 * 1000) { 16 | // If difference is > 1 day 17 | // show distance in days to conference start, so all entries have the same difference 18 | const daysDistance = differenceInCalendarDays(conferenceDate, now) 19 | return daysDistance > 1 ? `${daysDistance} days` : '1 day' 20 | } 21 | return formatDistance(startTime, now).replace('about ', '') 22 | } 23 | 24 | const startsInMinutes = (startTime: Date) => { 25 | return Math.floor((startTime.getTime() - Date.now()) / 1000 / 60) 26 | } 27 | 28 | const Countdown = ({ 29 | startTime, 30 | warnTime, 31 | conferenceDate, 32 | track, 33 | }: { 34 | startTime: Date 35 | conferenceDate: Date 36 | warnTime?: number 37 | track?: string 38 | }) => { 39 | const [timeToStart, setTimeToStart] = useState({ 40 | text: diff(startTime, conferenceDate), 41 | minutes: startsInMinutes(startTime), 42 | }) 43 | useEffect(() => { 44 | const interval = setInterval(() => { 45 | setTimeToStart({ 46 | text: diff(startTime, conferenceDate), 47 | minutes: startsInMinutes(startTime), 48 | }) 49 | }, 1000 * 60) 50 | 51 | return () => clearInterval(interval) 52 | }, [startTime, conferenceDate]) 53 | return ( 54 | = 0 && timeToStart.minutes <= (warnTime ?? 5) 57 | ? 'time hot' 58 | : 'time' 59 | } 60 | > 61 | {timeToStart.minutes >= 0 && timeToStart.text} 62 | {timeToStart.minutes < 0 && '-'} 63 | {track !== undefined ? ( 64 | 65 |
66 | {track} 67 |
68 | ) : ( 69 | '' 70 | )} 71 | 72 | ) 73 | } 74 | 75 | const formatTimezone = (tz: string) => tz.split('/')[1].replace('_', ' ') 76 | 77 | export const Schedule = ({ 78 | conferenceDate, 79 | eventTimezoneName, 80 | sessions, 81 | hidePastSessions, 82 | }: { 83 | conferenceDate: string 84 | eventTimezoneName: string 85 | sessions: { [key: number]: string } 86 | hidePastSessions: boolean 87 | }) => { 88 | const userTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone 89 | const eventTime = toEventTime({ conferenceDate, userTimeZone }) 90 | const userTime = toUserTime({ conferenceDate, eventTimezoneName }) 91 | const userFormat = formatUserTime({ userTimeZone }) 92 | const [currentTime, setCurrentTime] = useState(new Date()) 93 | 94 | useEffect(() => { 95 | const i = setInterval(() => { 96 | setCurrentTime(new Date()) 97 | }, 60 * 1000) 98 | return () => { 99 | clearInterval(i) 100 | } 101 | }, []) 102 | 103 | const ongoing = ongoingSessions({ 104 | day: conferenceDate, 105 | sessions, 106 | tz: eventTimezoneName, 107 | }) 108 | 109 | return ( 110 | 111 | 112 | 113 | 118 | 125 | 130 | 131 | 132 | 133 | 134 | {Object.entries(sessions) 135 | .sort( 136 | ([timeWithTrackA], [timeWithTrackB]) => 137 | parseInt(timeWithTrackA.split('@')[0]) - 138 | parseInt(timeWithTrackB.split('@')[0]), 139 | ) 140 | .map((session) => { 141 | const timeWithTrack = session[0] 142 | const time = parseInt(timeWithTrack.split('@')[0], 10) 143 | const isOngoing = ongoing[session[0]] !== undefined 144 | const isPast = startsInMinutes(userTime(time)) < 0 && !isOngoing 145 | return { session, isPast, isOngoing } 146 | }) 147 | .filter(({ isPast }) => (hidePastSessions ? !isPast : true)) 148 | .map(({ session: [timeWithTrack, name], isOngoing }) => { 149 | const [time, track] = timeWithTrack.split('@') 150 | return ( 151 | 152 | 155 | 158 | {isOngoing && ( 159 | 170 | )} 171 | {!isOngoing && ( 172 | 178 | )} 179 | 182 | 183 | ) 184 | })} 185 | 186 |
114 | Conf Time 115 |
116 | {formatTimezone(eventTimezoneName)} 117 |
119 | Your Time 120 |
121 | 122 | {formatTimezone(userTimeZone)} ({userFormat(currentTime)}) 123 | 124 |
126 | Starts in 127 |
128 | Track/Room 129 |
Session
153 | {formatEventTime(eventTime(time as unknown as number))} 154 | 156 | {userFormat(userTime(time as unknown as number))} 157 | 160 | ongoing 161 | {track !== undefined ? ( 162 | 163 |
164 | {track} 165 |
166 | ) : ( 167 | '' 168 | )} 169 |
180 | 181 |
187 | ) 188 | } 189 | -------------------------------------------------------------------------------- /src/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { AddIcon, DeleteIcon } from 'app/FeatherIcons' 2 | import formStyles from 'app/Form.module.css' 3 | import { SessionName } from 'app/SessionName' 4 | import tableStyles from 'app/Table.module.css' 5 | import { formatEventTime, toEventTime } from 'app/time' 6 | import { useRef, useState } from 'react' 7 | 8 | type AddSession = { 9 | name: string 10 | hour: string 11 | minute: string 12 | url: string 13 | track: string 14 | } 15 | 16 | const toNumber = (n: string) => { 17 | try { 18 | return parseInt(n, 10) 19 | } catch { 20 | return null 21 | } 22 | } 23 | 24 | const urlRegex = 25 | /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/ 26 | 27 | export const Editor = ({ 28 | conferenceDate, 29 | sessions, 30 | onAdd, 31 | onDelete, 32 | }: { 33 | conferenceDate: string 34 | eventTimezoneName: string 35 | sessions: Sessions 36 | onAdd: (newSession: AddSession) => void 37 | onDelete: (timeWithTrackA: string) => void 38 | }) => { 39 | const [add, updateAdd] = useState({ 40 | name: '', 41 | hour: '19', 42 | minute: '45', 43 | url: '', 44 | track: '', 45 | }) 46 | const inputRef = useRef(null) 47 | const isInputValid = () => { 48 | const hour = toNumber(add.hour) 49 | const minute = toNumber(add.minute) 50 | const urlIsValid = add.url !== '' ? urlRegex.test(add.url) : true 51 | return ( 52 | add.name.length > 0 && 53 | hour !== null && 54 | hour >= 0 && 55 | hour <= 23 && 56 | minute !== null && 57 | minute >= 0 && 58 | minute <= 59 && 59 | urlIsValid 60 | ) 61 | } 62 | 63 | const userTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone 64 | const eventTime = toEventTime({ conferenceDate, userTimeZone }) 65 | 66 | const addAction = (add: AddSession) => { 67 | onAdd(add) 68 | updateAdd({ 69 | ...add, 70 | name: '', 71 | url: '', 72 | track: '', 73 | }) 74 | inputRef.current?.focus() 75 | } 76 | 77 | return ( 78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 100 | 152 | 205 | 206 | {Object.entries(sessions) 207 | .sort( 208 | ([timeWithTrackA], [timeWithTrackB]) => 209 | parseInt(timeWithTrackA.split('@')[0]) - 210 | parseInt(timeWithTrackB.split('@')[0]), 211 | ) 212 | .map(([timeWithTrack, name]) => { 213 | const [time, track] = timeWithTrack.split('@') 214 | return ( 215 | 216 | 226 | 230 | 233 | 234 | ) 235 | })} 236 | 237 |
Conf TimeSession
90 | 99 | 101 |
102 | Local time 103 | { 110 | updateAdd({ 111 | ...add, 112 | hour: value, 113 | }) 114 | }} 115 | name="session-hour" 116 | maxLength={2} 117 | /> 118 | {':'} 119 | { 125 | updateAdd({ 126 | ...add, 127 | minute: value, 128 | }) 129 | }} 130 | name="session-minute" 131 | maxLength={2} 132 | /> 133 |
134 |
135 | 136 | 142 | updateAdd({ 143 | ...add, 144 | track: value, 145 | }) 146 | } 147 | placeholder='e.g. "Main room"' 148 | name="session-track" 149 | /> 150 |
151 |
153 |
154 | 155 | { 161 | if (key === 'Enter') { 162 | if (isInputValid()) { 163 | addAction(add) 164 | onAdd(add) 165 | } 166 | } 167 | }} 168 | onChange={({ target: { value } }) => 169 | updateAdd({ 170 | ...add, 171 | name: value, 172 | }) 173 | } 174 | placeholder='e.g. "Intro Session"' 175 | name="session-name" 176 | /> 177 |
178 |
179 | 182 | 188 | updateAdd({ 189 | ...add, 190 | url: value, 191 | }) 192 | } 193 | onKeyUp={({ key }) => { 194 | if (key === 'Enter') { 195 | if (isInputValid()) { 196 | addAction(add) 197 | onAdd(add) 198 | } 199 | } 200 | }} 201 | placeholder='e.g. "https://example.com/"' 202 | /> 203 |
204 |
217 | 225 | 227 | {formatEventTime(eventTime(parseInt(time, 10)))} 228 | {track === undefined ? '' : ` @ ${track}`} 229 | 231 | 232 |
238 |
239 | ) 240 | } 241 | -------------------------------------------------------------------------------- /src/FeatherIcons.tsx: -------------------------------------------------------------------------------- 1 | import styles from 'app/FeatherIcons.module.css' 2 | import { 3 | FeatherIcon as FeatherIconType, 4 | icons as featherIcons, 5 | } from 'feather-icons' 6 | 7 | type IconOptions = { 8 | /** defaults to 1.5 */ 9 | strokeWidth?: number 10 | /** defaults to 'inherit' */ 11 | color?: string 12 | /** defaults to 20 */ 13 | size?: number 14 | title: string 15 | className?: string 16 | } 17 | 18 | const defaultIconSize = 24 19 | const defaultStrokeWidth = 2 20 | 21 | // Must be wrapped in an element: https://github.com/reactjs/rfcs/pull/129 22 | const wrapSvg = (options: IconOptions) => (f: FeatherIconType) => ( 23 | 41 | ) 42 | 43 | type FeatherIconIdentifier = 44 | | 'activity' 45 | | 'airplay' 46 | | 'alert-circle' 47 | | 'alert-octagon' 48 | | 'alert-triangle' 49 | | 'align-center' 50 | | 'align-justify' 51 | | 'align-left' 52 | | 'align-right' 53 | | 'anchor' 54 | | 'aperture' 55 | | 'archive' 56 | | 'arrow-down-circle' 57 | | 'arrow-down-left' 58 | | 'arrow-down-right' 59 | | 'arrow-down' 60 | | 'arrow-left-circle' 61 | | 'arrow-left' 62 | | 'arrow-right-circle' 63 | | 'arrow-right' 64 | | 'arrow-up-circle' 65 | | 'arrow-up-left' 66 | | 'arrow-up-right' 67 | | 'arrow-up' 68 | | 'at-sign' 69 | | 'award' 70 | | 'bar-chart-2' 71 | | 'bar-chart' 72 | | 'battery-charging' 73 | | 'battery' 74 | | 'bell-off' 75 | | 'bell' 76 | | 'bluetooth' 77 | | 'bold' 78 | | 'bookmark' 79 | | 'book-open' 80 | | 'book' 81 | | 'box' 82 | | 'briefcase' 83 | | 'calendar' 84 | | 'camera-off' 85 | | 'camera' 86 | | 'cast' 87 | | 'check-circle' 88 | | 'check-square' 89 | | 'check' 90 | | 'chevron-down' 91 | | 'chevron-left' 92 | | 'chevron-right' 93 | | 'chevrons-down' 94 | | 'chevrons-left' 95 | | 'chevrons-right' 96 | | 'chevrons-up' 97 | | 'chevron-up' 98 | | 'chrome' 99 | | 'circle' 100 | | 'clipboard' 101 | | 'clock' 102 | | 'cloud-drizzle' 103 | | 'cloud-lightning' 104 | | 'cloud-off' 105 | | 'cloud-rain' 106 | | 'cloud-snow' 107 | | 'cloud' 108 | | 'codepen' 109 | | 'codesandbox' 110 | | 'code' 111 | | 'coffee' 112 | | 'columns' 113 | | 'command' 114 | | 'compass' 115 | | 'copy' 116 | | 'corner-down-left' 117 | | 'corner-down-right' 118 | | 'corner-left-down' 119 | | 'corner-left-up' 120 | | 'corner-right-down' 121 | | 'corner-right-up' 122 | | 'corner-up-left' 123 | | 'corner-up-right' 124 | | 'cpu' 125 | | 'credit-card' 126 | | 'crop' 127 | | 'crosshair' 128 | | 'database' 129 | | 'delete' 130 | | 'disc' 131 | | 'divide-circle' 132 | | 'divide-square' 133 | | 'divide' 134 | | 'dollar-sign' 135 | | 'download-cloud' 136 | | 'download' 137 | | 'dribbble' 138 | | 'droplet' 139 | | 'edit-2' 140 | | 'edit-3' 141 | | 'edit' 142 | | 'external-link' 143 | | 'eye-off' 144 | | 'eye' 145 | | 'facebook' 146 | | 'fast-forward' 147 | | 'feather' 148 | | 'figma' 149 | | 'file-minus' 150 | | 'file-plus' 151 | | 'file' 152 | | 'file-text' 153 | | 'film' 154 | | 'filter' 155 | | 'flag' 156 | | 'folder-minus' 157 | | 'folder-plus' 158 | | 'folder' 159 | | 'framer' 160 | | 'frown' 161 | | 'gift' 162 | | 'git-branch' 163 | | 'git-commit' 164 | | 'github' 165 | | 'gitlab' 166 | | 'git-merge' 167 | | 'git-pull-request' 168 | | 'globe' 169 | | 'grid' 170 | | 'hard-drive' 171 | | 'hash' 172 | | 'headphones' 173 | | 'heart' 174 | | 'help-circle' 175 | | 'hexagon' 176 | | 'home' 177 | | 'image' 178 | | 'inbox' 179 | | 'info' 180 | | 'instagram' 181 | | 'italic' 182 | | 'key' 183 | | 'layers' 184 | | 'layout' 185 | | 'life-buoy' 186 | | 'link-2' 187 | | 'linkedin' 188 | | 'link' 189 | | 'list' 190 | | 'loader' 191 | | 'lock' 192 | | 'log-in' 193 | | 'log-out' 194 | | 'mail' 195 | | 'map-pin' 196 | | 'map' 197 | | 'maximize-2' 198 | | 'maximize' 199 | | 'meh' 200 | | 'menu' 201 | | 'message-circle' 202 | | 'message-square' 203 | | 'mic-off' 204 | | 'mic' 205 | | 'minimize-2' 206 | | 'minimize' 207 | | 'minus-circle' 208 | | 'minus-square' 209 | | 'minus' 210 | | 'monitor' 211 | | 'moon' 212 | | 'more-horizontal' 213 | | 'more-vertical' 214 | | 'mouse-pointer' 215 | | 'move' 216 | | 'music' 217 | | 'navigation-2' 218 | | 'navigation' 219 | | 'octagon' 220 | | 'package' 221 | | 'paperclip' 222 | | 'pause-circle' 223 | | 'pause' 224 | | 'pen-tool' 225 | | 'percent' 226 | | 'phone-call' 227 | | 'phone-forwarded' 228 | | 'phone-incoming' 229 | | 'phone-missed' 230 | | 'phone-off' 231 | | 'phone-outgoing' 232 | | 'phone' 233 | | 'pie-chart' 234 | | 'play-circle' 235 | | 'play' 236 | | 'plus-circle' 237 | | 'plus-square' 238 | | 'plus' 239 | | 'pocket' 240 | | 'power' 241 | | 'printer' 242 | | 'radio' 243 | | 'refresh-ccw' 244 | | 'refresh-cw' 245 | | 'repeat' 246 | | 'rewind' 247 | | 'rotate-ccw' 248 | | 'rotate-cw' 249 | | 'rss' 250 | | 'save' 251 | | 'scissors' 252 | | 'search' 253 | | 'send' 254 | | 'server' 255 | | 'settings' 256 | | 'share-2' 257 | | 'share' 258 | | 'shield-off' 259 | | 'shield' 260 | | 'shopping-bag' 261 | | 'shopping-cart' 262 | | 'shuffle' 263 | | 'sidebar' 264 | | 'skip-back' 265 | | 'skip-forward' 266 | | 'slack' 267 | | 'slash' 268 | | 'sliders' 269 | | 'smartphone' 270 | | 'smile' 271 | | 'speaker' 272 | | 'square' 273 | | 'star' 274 | | 'stop-circle' 275 | | 'sunrise' 276 | | 'sunset' 277 | | 'sun' 278 | | 'tablet' 279 | | 'tag' 280 | | 'target' 281 | | 'terminal' 282 | | 'thermometer' 283 | | 'thumbs-down' 284 | | 'thumbs-up' 285 | | 'toggle-left' 286 | | 'toggle-right' 287 | | 'tool' 288 | | 'trash-2' 289 | | 'trash' 290 | | 'trello' 291 | | 'trending-down' 292 | | 'trending-up' 293 | | 'triangle' 294 | | 'truck' 295 | | 'tv' 296 | | 'twitch' 297 | | 'twitter' 298 | | 'type' 299 | | 'umbrella' 300 | | 'underline' 301 | | 'unlock' 302 | | 'upload-cloud' 303 | | 'upload' 304 | | 'user-check' 305 | | 'user-minus' 306 | | 'user-plus' 307 | | 'users' 308 | | 'user' 309 | | 'user-x' 310 | | 'video-off' 311 | | 'video' 312 | | 'voicemail' 313 | | 'volume-1' 314 | | 'volume-2' 315 | | 'volume' 316 | | 'volume-x' 317 | | 'watch' 318 | | 'wifi-off' 319 | | 'wifi' 320 | | 'wind' 321 | | 'x-circle' 322 | | 'x-octagon' 323 | | 'x-square' 324 | | 'x' 325 | | 'youtube' 326 | | 'zap-off' 327 | | 'zap' 328 | | 'zoom-in' 329 | | 'zoom-out' 330 | 331 | export const FeatherIcon = ({ 332 | type, 333 | ...options 334 | }: { type: FeatherIconIdentifier } & IconOptions) => 335 | wrapSvg(options)(featherIcons[type]) 336 | 337 | type TypedIconOptions = Omit 338 | 339 | export const EyeIcon = (options?: TypedIconOptions) => ( 340 | 341 | ) 342 | export const EyeOffIcon = (options?: TypedIconOptions) => ( 343 | 344 | ) 345 | export const LockIcon = (options?: TypedIconOptions) => ( 346 | 347 | ) 348 | export const UnLockIcon = (options?: TypedIconOptions) => ( 349 | 350 | ) 351 | 352 | export const AddIcon = (options?: TypedIconOptions) => ( 353 | 354 | ) 355 | export const DeleteIcon = (options?: TypedIconOptions) => ( 356 | 357 | ) 358 | 359 | export const DarkModeIcon = (options?: TypedIconOptions) => ( 360 | 361 | ) 362 | export const LightModeIcon = (options?: TypedIconOptions) => ( 363 | 364 | ) 365 | export const GithubIcon = (options?: TypedIconOptions) => ( 366 | 367 | ) 368 | 369 | export const CalendarIcon = (options?: TypedIconOptions) => ( 370 | 371 | ) 372 | 373 | export const StarIcon = (options?: TypedIconOptions) => ( 374 | 375 | ) 376 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import styles from 'app/App.module.css' 2 | import { DaySelector } from 'app/DaySelector' 3 | import { Editor } from 'app/Editor' 4 | import { 5 | CalendarIcon, 6 | EyeIcon, 7 | EyeOffIcon, 8 | GithubIcon, 9 | LockIcon, 10 | StarIcon, 11 | UnLockIcon, 12 | } from 'app/FeatherIcons' 13 | import { Footer } from 'app/Footer' 14 | import formStyles from 'app/Form.module.css' 15 | import { Schedule as ScheduleComponent } from 'app/Schedule' 16 | import { Theme, ThemeSwitcher } from 'app/ThemeSwitcher' 17 | import { TimeZoneSelector } from 'app/timezones' 18 | import { useIcalExport } from 'app/useIcalExport' 19 | import { format } from 'date-fns' 20 | import { useEffect, useState } from 'react' 21 | 22 | let defaultTheme = Theme.light 23 | if (typeof window.matchMedia === 'function') { 24 | const match = window.matchMedia('(prefers-color-scheme: dark)') 25 | defaultTheme = match.matches ? Theme.dark : Theme.light 26 | } 27 | 28 | export const App = () => { 29 | let cfg: Schedule = { 30 | name: 'ExampleConf', 31 | day: format(new Date(), 'yyyy-MM-dd'), 32 | tz: 'Europe/Oslo', 33 | sessions: { 34 | 900: 'Arrival & Breakfast', 35 | 930: 'Opening & Marketplace', 36 | 1030: 'Session 1', 37 | 1130: 'Coffee Break', 38 | 1145: 'Session 2', 39 | 1245: 'Lunch Break', 40 | 1430: 'Session 3', 41 | 1530: 'Coffee Break', 42 | 1545: 'Session 4', 43 | '1545@Main hall': `You can have sessions at the same time, too!`, 44 | 1645: 'Coffee Break', 45 | 1700: 'Closing & Retro', 46 | 1730: 'Dinner Break', 47 | 1900: 'Evening Activities', 48 | 1946: `You can add links, too!|${import.meta.env.PUBLIC_HOMEPAGE}`, 49 | }, 50 | hidePastSessions: false, 51 | } 52 | const hash = 53 | new URLSearchParams(window.location.search).get('schedule') ?? 54 | new URL(window.location.href).hash?.slice(1) ?? 55 | false 56 | 57 | const hidePastSessionsDefault = 58 | new URLSearchParams(window.location.search).get('hidePastSessions') !== null 59 | 60 | if (hash) { 61 | const payload = decodeURIComponent(hash) 62 | cfg = { 63 | ...cfg, 64 | ...JSON.parse( 65 | payload.startsWith('v2:') ? payload.slice(3) : atob(payload), 66 | ), 67 | } 68 | } 69 | const [theme, updateTheme] = useState( 70 | (window.localStorage.getItem('theme') ?? defaultTheme) === 'dark' 71 | ? Theme.dark 72 | : Theme.light, 73 | ) 74 | const [updatedName, updateName] = useState(cfg.name) 75 | const [updatedDay, updateDay] = useState(cfg.day) 76 | const [updatedTimeZone, updateTimeZone] = useState(cfg.tz) 77 | const [updatedSessions, updateSessions] = useState(cfg.sessions) 78 | const [editing, setEditing] = useState(false) 79 | const [hidePastSessions, setHidePastSessions] = useState( 80 | hidePastSessionsDefault, 81 | ) 82 | 83 | const downloadIcal = useIcalExport(cfg) 84 | 85 | useEffect(() => { 86 | document.documentElement.setAttribute('data-theme', theme) 87 | }, [theme]) 88 | 89 | return ( 90 | <> 91 |
92 | {editing && ( 93 | <> 94 |
95 | 116 |
117 | updateName(value)} 122 | name="conference-name" 123 | /> 124 | 125 | updateTimeZone(value)} 128 | name="conference-timezone" 129 | /> 130 |
131 | 132 |
133 | { 135 | updateSessions((sessions) => { 136 | let time = parseInt(`${add.hour}${add.minute}`, 10).toString() 137 | if (add.track.length > 0) time = `${time}@${add.track}` 138 | return { 139 | ...sessions, 140 | [time]: `${add.name}${ 141 | add.url === '' ? '' : `|${add.url.toString()}` 142 | }`, 143 | } 144 | }) 145 | }} 146 | onDelete={(time) => { 147 | updateSessions((sessions) => { 148 | const s = { ...sessions } 149 | delete (s as { [key: string]: string })[time] 150 | return s 151 | }) 152 | }} 153 | conferenceDate={updatedDay} 154 | eventTimezoneName={updatedTimeZone} 155 | sessions={updatedSessions} 156 | /> 157 | 158 | )} 159 | {!editing && ( 160 | <> 161 |
162 | 172 |

173 | {updatedName}: {updatedDay} 174 |

175 |
176 | 183 | 184 | 185 | 194 | 202 | 203 |
204 |
205 | 211 | 212 | )} 213 |
214 |

215 | Click the icon to create your own schedule. When done, 216 | click the and share the updated URL. 217 |

218 |

Tips

219 |

220 | Schedules change! Share the URL to your localschedule using{' '} 221 | 226 | short.io 227 | {' '} 228 | which allows edits to the URL it redirects to. 229 |

230 |

231 | 236 | Notion 237 | {' '} 238 | user? Use{' '} 239 | 244 | this URL 245 | {' '} 246 | to embed it on any page. 247 |

248 |

249 | Like localschedule? 250 |

251 |

252 | Please{' '} 253 | 259 | 260 | {' '} 261 | it in on{' '} 262 | 268 | GitHub 269 | 270 | ! 271 |

272 |
273 |
274 |