From b69dcc3fb0810c399c99e911621bccf3906bd9f8 Mon Sep 17 00:00:00 2001 From: Jaco Greeff Date: Tue, 25 Aug 2020 11:29:01 +0200 Subject: [PATCH] Chain/Events calendar (#3505) * Scaffolding * next/prevMonth * WIP * Day layout * Something shows * Scheduler itesm * Fix clacls, basics are there * styled localized * Constant retrievals * Extraction cleanups * extraMargin on article.error * It works. * Fix council ids * merge yarn --- packages/apps-routing/src/calendar.ts | 20 ++ packages/apps-routing/src/index.ts | 2 + .../apps/public/locales/en/app-calendar.json | 3 + packages/apps/public/locales/en/index.json | 1 + packages/page-calendar/.skip-build | 0 packages/page-calendar/.skip-npm | 0 packages/page-calendar/LICENSE | 201 +++++++++++++++ packages/page-calendar/README.md | 1 + packages/page-calendar/package.json | 17 ++ packages/page-calendar/src/Day.tsx | 102 ++++++++ packages/page-calendar/src/DayHour.tsx | 98 +++++++ packages/page-calendar/src/DayItem.tsx | 126 +++++++++ packages/page-calendar/src/Month.tsx | 201 +++++++++++++++ packages/page-calendar/src/MonthDay.tsx | 94 +++++++ packages/page-calendar/src/constants.ts | 6 + packages/page-calendar/src/index.tsx | 77 ++++++ packages/page-calendar/src/translate.ts | 9 + packages/page-calendar/src/types.ts | 17 ++ packages/page-calendar/src/useScheduled.ts | 241 ++++++++++++++++++ .../react-components/src/ErrorBoundary.tsx | 2 +- packages/react-components/src/styles/index.ts | 8 + .../react-hooks/src/useAvailableSlashes.ts | 2 +- tsconfig.json | 2 + yarn.lock | 9 + 24 files changed, 1237 insertions(+), 2 deletions(-) create mode 100644 packages/apps-routing/src/calendar.ts create mode 100644 packages/apps/public/locales/en/app-calendar.json create mode 100644 packages/page-calendar/.skip-build create mode 100644 packages/page-calendar/.skip-npm create mode 100644 packages/page-calendar/LICENSE create mode 100644 packages/page-calendar/README.md create mode 100644 packages/page-calendar/package.json create mode 100644 packages/page-calendar/src/Day.tsx create mode 100644 packages/page-calendar/src/DayHour.tsx create mode 100644 packages/page-calendar/src/DayItem.tsx create mode 100644 packages/page-calendar/src/Month.tsx create mode 100644 packages/page-calendar/src/MonthDay.tsx create mode 100644 packages/page-calendar/src/constants.ts create mode 100644 packages/page-calendar/src/index.tsx create mode 100644 packages/page-calendar/src/translate.ts create mode 100644 packages/page-calendar/src/types.ts create mode 100644 packages/page-calendar/src/useScheduled.ts diff --git a/packages/apps-routing/src/calendar.ts b/packages/apps-routing/src/calendar.ts new file mode 100644 index 0000000000..e27ecbd84b --- /dev/null +++ b/packages/apps-routing/src/calendar.ts @@ -0,0 +1,20 @@ +// Copyright 2017-2020 @polkadot/apps-routing authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Route } from './types'; + +import Component from '@polkadot/app-calendar'; + +export default function create (t: (key: string, text: string, options: { ns: string }) => T): Route { + return { + Component, + display: { + needsApi: [] + }, + group: 'network', + icon: 'calendar-alt', + name: 'calendar', + text: t('nav.calendar', 'Event calendar', { ns: 'apps-routing' }) + }; +} diff --git a/packages/apps-routing/src/index.ts b/packages/apps-routing/src/index.ts index cef22409dc..9b7ce16bb2 100644 --- a/packages/apps-routing/src/index.ts +++ b/packages/apps-routing/src/index.ts @@ -6,6 +6,7 @@ import { Routes } from './types'; import accounts from './accounts'; import addresses from './addresses'; +import calendar from './calendar'; import claims from './claims'; import contracts from './contracts'; import council from './council'; @@ -45,6 +46,7 @@ export default function create (t: (key: string, text: string, opti techcomm(t), parachains(t), society(t), + calendar(t), contracts(t), storage(t), extrinsics(t), diff --git a/packages/apps/public/locales/en/app-calendar.json b/packages/apps/public/locales/en/app-calendar.json new file mode 100644 index 0000000000..784fc38253 --- /dev/null +++ b/packages/apps/public/locales/en/app-calendar.json @@ -0,0 +1,3 @@ +{ + "Upcoming events": "Upcoming events" +} \ No newline at end of file diff --git a/packages/apps/public/locales/en/index.json b/packages/apps/public/locales/en/index.json index 7cfb3df7e1..7ac619c08f 100644 --- a/packages/apps/public/locales/en/index.json +++ b/packages/apps/public/locales/en/index.json @@ -1,6 +1,7 @@ [ "app-accounts.json", "app-addresses.json", + "app-calendar.json", "app-claims.json", "app-contracts.json", "app-council.json", diff --git a/packages/page-calendar/.skip-build b/packages/page-calendar/.skip-build new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/page-calendar/.skip-npm b/packages/page-calendar/.skip-npm new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/page-calendar/LICENSE b/packages/page-calendar/LICENSE new file mode 100644 index 0000000000..0d381b2e97 --- /dev/null +++ b/packages/page-calendar/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/page-calendar/README.md b/packages/page-calendar/README.md new file mode 100644 index 0000000000..b2b42aa7bc --- /dev/null +++ b/packages/page-calendar/README.md @@ -0,0 +1 @@ +# @polkadot/app-calendar diff --git a/packages/page-calendar/package.json b/packages/page-calendar/package.json new file mode 100644 index 0000000000..df2aad1cd9 --- /dev/null +++ b/packages/page-calendar/package.json @@ -0,0 +1,17 @@ +{ + "name": "@polkadot/app-calendar", + "private": true, + "version": "0.56.0-beta.0", + "description": "A on-chain events calendar", + "main": "index.js", + "scripts": {}, + "author": "Jaco Greeff ", + "maintainers": [ + "Jaco Greeff " + ], + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@polkadot/react-components": "0.56.0-beta.0" + } +} diff --git a/packages/page-calendar/src/Day.tsx b/packages/page-calendar/src/Day.tsx new file mode 100644 index 0000000000..0309ee1301 --- /dev/null +++ b/packages/page-calendar/src/Day.tsx @@ -0,0 +1,102 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { EntryInfo } from './types'; + +import React, { useMemo, useRef } from 'react'; +import styled from 'styled-components'; +import { Button } from '@polkadot/react-components'; + +import DayHour from './DayHour'; +import { MONTHS } from './constants'; +import { useTranslation } from './translate'; + +interface Props { + className?: string; + date: Date; + now: Date; + scheduled: EntryInfo[]; +} + +const HOURS = ((): number[] => { + const hours: number[] = []; + + for (let i = 0; i < 24; i++) { + hours.push(i); + } + + return hours; +})(); + +const NOOP = () => undefined; + +function Day ({ className, date, now, scheduled }: Props): React.ReactElement { + const { t } = useTranslation(); + + const monthRef = useRef(MONTHS.map((m) => t(m))); + + const [isToday, nowOffset] = useMemo( + () => [ + date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear(), + now.getHours() + ], + [date, now] + ); + + const offset = isToday + ? nowOffset + : 0; + + return ( +
+
+
+
+ {HOURS.map((hour, index): React.ReactNode => + + )} +
+
+ ); +} + +export default React.memo(styled(Day)` + flex: 1; + margin-right: 1rem; + + .dayHeader { + align-items: center; + display: flex; + font-size: 1.25rem; + justify-content: space-between; + padding: 1rem 1.5rem 0; + + .ui--Button { + opacity: 0; + } + } + + .hoursContainer { + margin-top: 1.25em; + } +`); diff --git a/packages/page-calendar/src/DayHour.tsx b/packages/page-calendar/src/DayHour.tsx new file mode 100644 index 0000000000..7ea26863c3 --- /dev/null +++ b/packages/page-calendar/src/DayHour.tsx @@ -0,0 +1,98 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { EntryInfo } from './types'; + +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import DayItem from './DayItem'; + +interface Props { + className?: string; + date: Date; + hour: number; + index: number; + minutes: number; + offset: number; + scheduled: EntryInfo[]; +} + +const MN_TO_MS = 60 * 1000; +const HR_TO_MS = 60 * MN_TO_MS; + +function DayHour ({ className = '', date, hour, index, minutes, offset, scheduled }: Props): React.ReactElement | null { + const filtered = useMemo( + (): EntryInfo[] => { + const start = date.getTime() + ((offset + index) * HR_TO_MS); + const end = start + HR_TO_MS; + const explicit = start + (minutes * MN_TO_MS); + + return scheduled + .filter(({ dateTime }) => dateTime >= explicit && dateTime < end) + .sort((a, b) => (a.dateTime - b.dateTime) || a.type.localeCompare(b.type)); + }, + [date, index, minutes, offset, scheduled] + ); + + const hourStr = `${` ${hour}`.slice(-2)} ${hour >= 12 ? 'pm' : 'am'}`; + + return ( +
+
{hourStr}
+
+ {filtered.map((item, index): React.ReactNode => ( + + ))} +
+
+ ); +} + +export default React.memo(styled(DayHour)` + align-items: center; + display: flex; + position: relative; + z-index: 2; + + &:nth-child(even) { + background: #faf8f6; + } + + &:nth-child(odd) { + background: white; + } + + &.isPast { + opacity: 0.75; + } + + .hourContainer { + flex: 1; + padding: 0.25rem 0; + } + + .hourLabel { + flex: 0; + font-size: 0.85rem; + font-weight: 100; + line-height: 1; + min-width: 5.5rem; + opacity: 0.5; + padding: 0.25rem 1rem; + text-align: right; + text-transform: uppercase; + z-index: 1; + } + + &.hasItems .hourLabel { + font-size: 1.1rem; + font-weight: normal; + opacity: 1; + padding: 0.7rem 1rem; + } +`); diff --git a/packages/page-calendar/src/DayItem.tsx b/packages/page-calendar/src/DayItem.tsx new file mode 100644 index 0000000000..c70fe6f704 --- /dev/null +++ b/packages/page-calendar/src/DayItem.tsx @@ -0,0 +1,126 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { EntryInfo } from './types'; + +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { formatNumber, isString } from '@polkadot/util'; + +import { useTranslation } from './translate'; + +interface Props { + className?: string; + item: EntryInfo; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function assertUnreachable (x: never): never { + throw new Error('We cannot get here'); +} + +function DayItem ({ className, item: { date, info, type } }: Props): React.ReactElement { + const { t } = useTranslation(); + + const desc = useMemo( + (): React.ReactNode => { + const id: string | null = info && ( + isString(info) + ? info + : formatNumber(info) + ); + const typeLink = ['councilElection'].includes(type) + ? + : ['councilMotion'].includes(type) + ? + : ['democracyDispatch', 'scheduler'].includes(type) + ? + : ['democracyLaunch', 'referendumDispatch', 'referendumVote'].includes(type) + ? + : ['societyChallenge', 'societyRotate'].includes(type) + ? + : ['stakingEra', 'stakingSession'].includes(type) + ? + : ['stakingSlash'].includes(type) + ? + : ['treasurySpend'].includes(type) + ? + : undefined; + + switch (type) { + case 'councilElection': + return <>
{t('Election of new council candidates')}
{typeLink}; + + case 'councilMotion': + return <>
{t('Voting ends on council motion {{id}}', { replace: { id } })}
{typeLink}; + + case 'democracyDispatch': + return <>
{t('Enactment of the result of referendum {{id}}', { replace: { id } })}
{typeLink}; + + case 'democracyLaunch': + return <>
{t('Start of the next referendum voting period')}
{typeLink}; + + case 'referendumDispatch': + return <>
{t('Potential dispatch of referendum {{id}} (if passed)', { replace: { id } })}
{typeLink}; + + case 'referendumVote': + return <>
{t('Voting ends for referendum {{id}}', { replace: { id } })}
{typeLink}; + + case 'scheduler': + return <>
{ + id + ? t('Execute named scheduled task {{id}}', { replace: { id } }) + : t('Execute anonymous scheduled task') + }
{typeLink}; + + case 'stakingEra': + return <>
{t('Start of a new staking era {{id}}', { replace: { id } })}
{typeLink}; + + case 'stakingSession': + return <>
{t('Start of a new staking session {{id}}', { replace: { id } })}
{typeLink}; + + case 'stakingSlash': + return <>
{t('Application of slashes from era {{id}}', { replace: { id } })}
{typeLink}; + + case 'treasurySpend': + return <>
{t('Start of next spending period')}
{typeLink}; + + case 'societyChallenge': + return <>
{t('Start of next membership challenge period')}
{typeLink}; + + case 'societyRotate': + return <>
{t('Acceptance of new members and bids')}
{typeLink}; + + default: + return assertUnreachable(type); + } + }, + [info, t, type] + ); + + return ( +
+
{date.toLocaleTimeString().split(':').slice(0, 2).join(':')}
+ {desc} +
+ ); +} + +export default React.memo(styled(DayItem)` + align-items: flex-start; + display: flex; + justify-content: flex-start; + margin: 0.5rem 0.75rem; + + > div+div { + margin-left: 0.5rem; + } + + .itemTime { + background: #999; + padding: 0 0.375rem; + border-radius: 0.25rem; + color: #eee; + } +`); diff --git a/packages/page-calendar/src/Month.tsx b/packages/page-calendar/src/Month.tsx new file mode 100644 index 0000000000..2ef67ef5f8 --- /dev/null +++ b/packages/page-calendar/src/Month.tsx @@ -0,0 +1,201 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { EntryInfo } from './types'; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { Button } from '@polkadot/react-components'; + +import { DAYS, MONTHS } from './constants'; +import { useTranslation } from './translate'; +import MonthDay from './MonthDay'; + +interface Props { + className?: string; + now: Date; + onChange: (date: Date) => void; + scheduled: EntryInfo[]; +} + +interface DateState { + dateMonth: Date; + dateSelected: Date; + days: number[]; + startClass: string; +} + +function newZeroDate (input: Date): Date { + const date = new Date(input); + + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + + return date; +} + +function nextMonth (date: Date, firstDay = 1): Date { + const currMonth = date.getMonth(); + + return currMonth === 11 + ? new Date(date.getFullYear() + 1, 0, firstDay) + : new Date(date.getFullYear(), currMonth + 1, firstDay); +} + +function prevMonth (date: Date): Date { + const currMonth = date.getMonth(); + + return currMonth === 0 + ? new Date(date.getFullYear() - 1, 11, 1) + : new Date(date.getFullYear(), currMonth - 1, 1); +} + +function getDateState (_dateMonth: Date, _dateSelected: Date): DateState { + const dateMonth = newZeroDate(_dateMonth); + const dateSelected = newZeroDate(_dateSelected); + + dateMonth.setDate(1); + + const numDays = nextMonth(dateMonth, 0).getDate(); + const days: number[] = []; + + for (let i = 1; i <= numDays; i++) { + days.push(i); + } + + return { + dateMonth, + dateSelected, + days, + startClass: `start${DAYS[dateMonth.getDay()]}` + }; +} + +function Month ({ className, now, onChange, scheduled }: Props): React.ReactElement { + const { t } = useTranslation(); + const [{ dateMonth, dateSelected, days, startClass }, setDate] = useState(getDateState(now, now)); + + const dayOfWeekRef = useRef(DAYS.map((d) => t(d))); + const monthRef = useRef(MONTHS.map((m) => t(m))); + + const _nextMonth = useCallback( + () => setDate(({ dateMonth, dateSelected }) => getDateState(nextMonth(dateMonth), dateSelected)), + [] + ); + + const _prevMonth = useCallback( + () => setDate(({ dateMonth, dateSelected }) => getDateState(prevMonth(dateMonth), dateSelected)), + [] + ); + + const _setDay = useCallback( + (day: number) => setDate(({ dateMonth }): DateState => { + const date = new Date(dateMonth); + + date.setDate(day); + + return getDateState(date, date); + }), + [] + ); + + useEffect((): void => { + onChange(dateSelected); + }, [dateSelected, onChange]); + + const [isCurrYear, isCurrMonth, isNowYear, isNowMonth, isOlderMonth] = useMemo( + () => [ + dateMonth.getFullYear() === dateSelected.getFullYear(), + dateMonth.getMonth() === dateSelected.getMonth(), + now.getFullYear() === dateMonth.getFullYear(), + now.getMonth() === dateMonth.getMonth(), + now.getMonth() > dateMonth.getMonth() + ], + [dateMonth, dateSelected, now] + ); + + return ( +
+
+
+
+
+ {dayOfWeekRef.current.map((day): React.ReactNode => ( +
{t(day)}
+ ))} +
+
+ {days.map((day): React.ReactNode => ( + day))} + key={day} + scheduled={scheduled} + setDay={_setDay} + /> + ))} +
+
+
+ ); +} + +export default React.memo(styled(Month)` + flex: 0; + max-width: max-content; + padding: 1rem 1.5rem; + + .calendar { + .dateGrid, + .dayOfWeek { + display: grid; + grid-template-columns: repeat(7, 1fr); + } + + .dateGrid { + margin-top: 0.5em; + } + + &.startSun .dateGrid .day:first-child { grid-column: 1 } + &.startMon .dateGrid .day:first-child { grid-column: 2 } + &.startTue .dateGrid .day:first-child { grid-column: 3 } + &.startWed .dateGrid .day:first-child { grid-column: 4 } + &.startThu .dateGrid .day:first-child { grid-column: 5 } + &.startFri .dateGrid .day:first-child { grid-column: 6 } + &.startSat .dateGrid .day:first-child { grid-column: 7 } + + .dayOfWeek { + margin-top: 1.25em; + + > * { + font-size: 0.7em; + font-weight: 500; + letter-spacing: 0.1em; + text-align: center; + text-transform: uppercase; + } + } + + .monthIndicator { + align-items: center; + display: flex; + font-size: 1.25rem; + justify-content: space-between; + } + } +`); diff --git a/packages/page-calendar/src/MonthDay.tsx b/packages/page-calendar/src/MonthDay.tsx new file mode 100644 index 0000000000..79ef7da020 --- /dev/null +++ b/packages/page-calendar/src/MonthDay.tsx @@ -0,0 +1,94 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { EntryInfo } from './types'; + +import React, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; + +interface Props { + className?: string; + dateMonth: Date; + day: number; + isCurrent: boolean; + isDisabled: boolean; + setDay: (day: number) => void; + scheduled: EntryInfo[]; +} + +const DAY_TO_MS = 24 * 60 * 60 * 1000; + +function MonthDay ({ className = '', dateMonth, day, isCurrent, isDisabled, scheduled, setDay }: Props): React.ReactElement { + const hasEvents = useMemo( + (): boolean => { + const start = dateMonth.getTime() + ((day - 1) * DAY_TO_MS); + const end = start + DAY_TO_MS; + + return scheduled.some(({ dateTime }) => dateTime >= start && dateTime < end); + }, + [dateMonth, day, scheduled] + ); + + const _onClick = useCallback( + (): void => { + !isDisabled && setDay(day); + }, + [day, isDisabled, setDay] + ); + + return ( +
+ {day} + {hasEvents &&
} +
+ ); +} + +export default React.memo(styled(MonthDay)` + background-color: transparent; + border: 1px solid transparent; + border-radius: 50%; + line-height: 1; + padding: 1rem; + position: relative; + text-align: center; + z-index: 1; + + &:before { + border-radius: 50%; + } + + &:not(.isDisabled) { + cursor: pointer; + } + + &:not(.isSelected):hover { + background: #f7f5f3; + } + + .eventIndicator { + border: 0.25rem solid transparent; + border-radius: 50%; + height: 0.25rem; + position: absolute; + right: 0.625rem; + top: 0.625rem; + width: 0.25rem; + } + + &.isDisabled { + opacity: 0.375; + + &:hover { + background: transparent; + } + + .eventIndicator { + display: none; + } + } +`); diff --git a/packages/page-calendar/src/constants.ts b/packages/page-calendar/src/constants.ts new file mode 100644 index 0000000000..4d6d516de6 --- /dev/null +++ b/packages/page-calendar/src/constants.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +export const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; diff --git a/packages/page-calendar/src/index.tsx b/packages/page-calendar/src/index.tsx new file mode 100644 index 0000000000..620d5c7c36 --- /dev/null +++ b/packages/page-calendar/src/index.tsx @@ -0,0 +1,77 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import React, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { Tabs } from '@polkadot/react-components'; + +import Day from './Day'; +import Month from './Month'; +import { useTranslation } from './translate'; +import useScheduled from './useScheduled'; + +interface Props { + basePath: string; + className?: string; +} + +const NOW_INC = 30 * 1000; + +function CalendarApp ({ basePath, className }: Props): React.ReactElement { + const { t } = useTranslation(); + const scheduled = useScheduled(); + const [now, setNow] = useState(new Date()); + const [selected, setSelected] = useState(new Date()); + + const itemsRef = useRef([{ + isRoot: true, + name: 'view', + text: t('Upcoming events') + }]); + + useEffect((): () => void => { + const intervalId = setInterval(() => setNow(new Date()), NOW_INC); + + return (): void => { + clearInterval(intervalId); + }; + }, []); + + return ( +
+
+ +
+
+ + +
+
+ ); +} + +export default React.memo(styled(CalendarApp)` + .calendarFlex { + align-items: flex-start; + display: flex; + flex-wrap: nowrap; + + > div { + background-color: #fff; + border: 1px solid #ddd; + border-radius: 0.25rem; + } + } +`); diff --git a/packages/page-calendar/src/translate.ts b/packages/page-calendar/src/translate.ts new file mode 100644 index 0000000000..c9aec95baa --- /dev/null +++ b/packages/page-calendar/src/translate.ts @@ -0,0 +1,9 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { useTranslation as useTranslationBase, UseTranslationResponse } from 'react-i18next'; + +export function useTranslation (): UseTranslationResponse { + return useTranslationBase('app-calendar'); +} diff --git a/packages/page-calendar/src/types.ts b/packages/page-calendar/src/types.ts new file mode 100644 index 0000000000..b6cee79da2 --- /dev/null +++ b/packages/page-calendar/src/types.ts @@ -0,0 +1,17 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import BN from 'bn.js'; + +export type EntryType = 'councilElection' | 'councilMotion' | 'democracyDispatch' | 'democracyLaunch' | 'referendumDispatch' | 'referendumVote' | 'scheduler' | 'societyChallenge' | 'societyRotate'| 'stakingEra' | 'stakingSession' | 'stakingSlash' | 'treasurySpend'; + +export interface EntryInfo { + blockNumber: BN; + blocks: BN; + date: Date; + dateTime: number; + info: string | BN | null; + isPending?: boolean; + type: EntryType; +} diff --git a/packages/page-calendar/src/useScheduled.ts b/packages/page-calendar/src/useScheduled.ts new file mode 100644 index 0000000000..83ac3a4812 --- /dev/null +++ b/packages/page-calendar/src/useScheduled.ts @@ -0,0 +1,241 @@ +// Copyright 2017-2020 @polkadot/app-calendar authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { DeriveCollectiveProposal, DeriveDispatch, DeriveReferendumExt, DeriveSessionProgress } from '@polkadot/api-derive/types'; +import { BlockNumber, EraIndex, Scheduled, UnappliedSlash } from '@polkadot/types/interfaces'; +import { EntryInfo, EntryType } from './types'; + +import BN from 'bn.js'; +import { useEffect, useState } from 'react'; +import { useApi, useBlockTime, useCall } from '@polkadot/react-hooks'; +import { Option } from '@polkadot/types'; +import { BN_ONE } from '@polkadot/util'; + +interface DateExt { + date: Date; + dateTime: number; +} + +type SlashEntry = [{ args: [EraIndex] }, UnappliedSlash[]]; + +type ScheduleEntry = [{ args: [BlockNumber] }, Option[]]; + +function newDate (remaining: BN, blockTime: number): DateExt { + const date = new Date(Date.now() + remaining.muln(blockTime).toNumber()); + + return { date, dateTime: date.getTime() }; +} + +function createConstDurations (bestNumber: BlockNumber, blockTime: number, items: [EntryType, BlockNumber?][]): [EntryType, EntryInfo[]][] { + return items.map(([type, duration]): [EntryType, EntryInfo[]] => { + if (!duration) { + return [type, []]; + } + + const blocks = duration.sub(bestNumber.mod(duration)); + + return [type, [{ + ...newDate(blocks, blockTime), + blockNumber: bestNumber.add(blocks), + blocks, + info: null, + type + }]]; + }); +} + +function createCouncilMotions (bestNumber: BlockNumber, blockTime: number, motions: DeriveCollectiveProposal[]): [EntryType, EntryInfo[]][] { + return motions + .map(({ hash, votes }): [EntryType, EntryInfo[]] | null => { + if (!votes) { + return null; + } + + const hashStr = hash.toHex(); + const blocks = votes.end.sub(bestNumber); + + return ['councilMotion', [{ + ...newDate(blocks, blockTime), + blockNumber: votes.end, + blocks, + info: `${hashStr.substr(0, 6)}…${hashStr.substr(-4)}`, + type: 'councilMotion' + }]]; + }) + .filter((item): item is [EntryType, EntryInfo[]] => !!item); +} + +function createDispatches (bestNumber: BlockNumber, blockTime: number, dispatches: DeriveDispatch[]): [EntryType, EntryInfo[]][] { + return dispatches.map(({ at, index }): [EntryType, EntryInfo[]] => { + const blocks = at.sub(bestNumber); + + return ['democracyDispatch', [{ + ...newDate(blocks, blockTime), + blockNumber: at, + blocks, + info: index, + type: 'democracyDispatch' + }]]; + }); +} + +function createReferendums (bestNumber: BlockNumber, blockTime: number, referendums: DeriveReferendumExt[]): [EntryType, EntryInfo[]][] { + return referendums.reduce((result: [EntryType, EntryInfo[]][], { index, status }): [EntryType, EntryInfo[]][] => { + const enactBlocks = status.end.add(status.delay).sub(bestNumber); + const voteBlocks = status.end.sub(bestNumber).subn(1); + + result.push(['referendumVote', [{ + ...newDate(voteBlocks, blockTime), + blockNumber: bestNumber.add(voteBlocks), + blocks: voteBlocks, + info: index, + type: 'referendumVote' + }]]); + result.push(['referendumDispatch', [{ + ...newDate(enactBlocks, blockTime), + blockNumber: bestNumber.add(enactBlocks), + blocks: enactBlocks, + info: index, + isPending: true, + type: 'referendumDispatch' + }]]); + + return result; + }, []); +} + +function createStakingInfo (bestNumber: BlockNumber, blockTime: number, sessionInfo: DeriveSessionProgress, unapplied: SlashEntry[], slashDeferDuration?: BlockNumber): [EntryType, EntryInfo[]][] { + const remainingEra = sessionInfo.eraLength.sub(sessionInfo.eraProgress); + const remainingSes = sessionInfo.sessionLength.sub(sessionInfo.sessionProgress); + const slashDuration = slashDeferDuration?.mul(sessionInfo.eraLength); + const slashEras = slashDuration + ? unapplied + .filter(([, values]) => values.length) + .map(([key]): EntryInfo => { + const eraIndex = key.args[0]; + const blockProgress = sessionInfo.activeEra.sub(eraIndex).subn(1).mul(sessionInfo.eraLength).add(sessionInfo.eraProgress); + const blocks = slashDuration.sub(blockProgress); + + return { + ...newDate(blocks, blockTime), + blockNumber: bestNumber.add(blocks), + blocks, + info: eraIndex, + type: 'stakingSlash' + }; + }) + : []; + + return [ + ['stakingEra', [{ + ...newDate(remainingEra, blockTime), + blockNumber: bestNumber.add(remainingEra), + blocks: remainingEra, + info: sessionInfo.activeEra.add(BN_ONE), + type: 'stakingEra' + }]], + ['stakingSession', [{ + ...newDate(remainingSes, blockTime), + blockNumber: bestNumber.add(remainingSes), + blocks: remainingSes, + info: sessionInfo.currentIndex.add(BN_ONE), + type: 'stakingSession' + }]], + ['stakingSlash', slashEras] + ]; +} + +function createScheduled (bestNumber: BlockNumber, blockTime: number, scheduled: ScheduleEntry[]): [EntryType, EntryInfo[]][] { + return [['scheduler', scheduled + .filter(([, vecSchedOpt]) => vecSchedOpt.some((schedOpt) => schedOpt.isSome)) + .reduce((items: EntryInfo[], [key, vecSchedOpt]): EntryInfo[] => { + const blockNumber = key.args[0]; + + return vecSchedOpt + .filter((schedOpt) => schedOpt.isSome) + .map((schedOpt) => schedOpt.unwrap()) + .reduce((items: EntryInfo[], { maybeId }) => { + const idOrNull = maybeId.unwrapOr(null); + const blocks = blockNumber.sub(bestNumber); + + items.push({ + ...newDate(blocks, blockTime), + blockNumber, + blocks, + info: idOrNull + ? idOrNull.isAscii + ? idOrNull.toUtf8() + : idOrNull.toHex() + : null, + type: 'scheduler' + }); + + return items; + }, items); + }, [])]]; +} + +function addFiltered (state: EntryInfo[], types: [EntryType, EntryInfo[]][]): EntryInfo[] { + return types.reduce((state: EntryInfo[], [typeFilter, items]): EntryInfo[] => + state.filter(({ type }) => type !== typeFilter).concat(...items), state + ); +} + +// TODO council votes, tips closing +export default function useScheduled (): EntryInfo[] { + const { api } = useApi(); + const [blockTime] = useBlockTime(); + const bestNumber = useCall(api.derive.chain.bestNumber); + const councilMotions = useCall(api.derive.council?.proposals); + const dispatches = useCall(api.derive.democracy?.dispatchQueue); + const referendums = useCall(api.derive.democracy?.referendums); + const scheduled = useCall(api.query.scheduler?.agenda.entries as any); + const sessionInfo = useCall(api.query.staking && api.derive.session?.progress); + const slashes = useCall(api.query.staking?.unappliedSlashes.entries as any); + const [state, setState] = useState([]); + + useEffect((): void => { + bestNumber && dispatches && setState((state) => + addFiltered(state, createDispatches(bestNumber, blockTime, dispatches)) + ); + }, [bestNumber, blockTime, dispatches]); + + useEffect((): void => { + bestNumber && councilMotions && setState((state) => + addFiltered(state, createCouncilMotions(bestNumber, blockTime, councilMotions)) + ); + }, [bestNumber, blockTime, councilMotions]); + + useEffect((): void => { + bestNumber && referendums && setState((state) => + addFiltered(state, createReferendums(bestNumber, blockTime, referendums)) + ); + }, [bestNumber, blockTime, referendums]); + + useEffect((): void => { + bestNumber && scheduled && setState((state) => + addFiltered(state, createScheduled(bestNumber, blockTime, scheduled)) + ); + }, [bestNumber, blockTime, scheduled]); + + useEffect((): void => { + bestNumber && sessionInfo?.sessionLength.gt(BN_ONE) && slashes && setState((state) => + addFiltered(state, createStakingInfo(bestNumber, blockTime, sessionInfo, slashes, api.consts.staking.slashDeferDuration)) + ); + }, [api, bestNumber, blockTime, sessionInfo, slashes]); + + useEffect((): void => { + bestNumber && setState((state) => + addFiltered(state, createConstDurations(bestNumber, blockTime, [ + ['councilElection', (api.consts.elections || api.consts.electionsPhragmen)?.termDuration], + ['democracyLaunch', api.consts.democracy?.launchPeriod], + ['societyChallenge', api.consts.society?.challengePeriod], + ['societyRotate', api.consts.society?.rotationPeriod], + ['treasurySpend', api.consts.treasury?.spendPeriod] + ])) + ); + }, [api, bestNumber, blockTime]); + + return state; +} diff --git a/packages/react-components/src/ErrorBoundary.tsx b/packages/react-components/src/ErrorBoundary.tsx index df00152cd1..75c7d1ff8d 100644 --- a/packages/react-components/src/ErrorBoundary.tsx +++ b/packages/react-components/src/ErrorBoundary.tsx @@ -52,7 +52,7 @@ class ErrorBoundary extends React.Component { return error ? ( -
+
{t('Uncaught error. Something went wrong with the query and rendering of this component. {{message}}', { replace: { message: error.message } })} diff --git a/packages/react-components/src/styles/index.ts b/packages/react-components/src/styles/index.ts index d02e0e386d..f46e355199 100644 --- a/packages/react-components/src/styles/index.ts +++ b/packages/react-components/src/styles/index.ts @@ -72,6 +72,10 @@ export default createGlobalStyle` background: ${(props: Props) => `linear-gradient(90deg, ${props.uiHighlight || defaultHighlight}, transparent)`}; } + .highlight--hover-bg:hover { + background: ${getHighlight} !important; + } + .highlight--hover-color:hover { color: ${getHighlight} !important; } @@ -219,6 +223,10 @@ export default createGlobalStyle` } } + &.extraMargin { + margin: 2rem auto; + } + &.nomargin { margin: 0.5rem auto; max-width: 75rem; diff --git a/packages/react-hooks/src/useAvailableSlashes.ts b/packages/react-hooks/src/useAvailableSlashes.ts index 20c48777da..21188d6586 100644 --- a/packages/react-hooks/src/useAvailableSlashes.ts +++ b/packages/react-hooks/src/useAvailableSlashes.ts @@ -38,7 +38,7 @@ export default function useAvailableSlashes (): [BN, UnappliedSlash[]][] { mountedRef.current && setSlashes( values .map((value, index): [BN, UnappliedSlash[]] => [from.addn(index), value]) - .filter(([, slashes]): boolean => slashes.length !== 0) + .filter(([, slashes]) => slashes.length) ); }); })().catch(console.error); diff --git a/tsconfig.json b/tsconfig.json index 78b9ef982f..96d948f540 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,8 @@ "@polkadot/app-accounts/*": [ "packages/page-accounts/src/*" ], "@polkadot/app-addresses": [ "packages/page-addresses/src" ], "@polkadot/app-addresses/*": [ "packages/page-addresses/src/*" ], + "@polkadot/app-calendar": [ "packages/page-calendar/src" ], + "@polkadot/app-calendar/*": [ "packages/page-calendar/src/*" ], "@polkadot/app-claims": [ "packages/page-claims/src" ], "@polkadot/app-claims/*": [ "packages/page-claims/src/*" ], "@polkadot/app-contracts": [ "packages/page-contracts/src" ], diff --git a/yarn.lock b/yarn.lock index 16001faa4c..9bac951887 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3137,6 +3137,15 @@ __metadata: languageName: unknown linkType: soft +"@polkadot/app-calendar@workspace:packages/page-calendar": + version: 0.0.0-use.local + resolution: "@polkadot/app-calendar@workspace:packages/page-calendar" + dependencies: + "@babel/runtime": ^7.11.2 + "@polkadot/react-components": 0.56.0-beta.0 + languageName: unknown + linkType: soft + "@polkadot/app-claims@workspace:packages/page-claims": version: 0.0.0-use.local resolution: "@polkadot/app-claims@workspace:packages/page-claims"