feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { EntryInfoTyped } from './types.js';
import React, { useCallback, useMemo, useRef } from 'react';
import { Button, styled } from '@pezkuwi/react-components';
import { MONTHS } from './constants.js';
import DayHour from './DayHour.js';
import DayTime from './DayTime.js';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
date: Date;
hasNextDay: boolean;
now: Date;
scheduled: EntryInfoTyped[];
setNextDay: () => void;
setPrevDay: () => void;
setView: (v: boolean) => void;
}
const HOURS = ((): number[] => {
const hours: number[] = [];
for (let i = 0; i < 24; i++) {
hours.push(i);
}
return hours;
})();
function Day ({ className, date, hasNextDay, now, scheduled, setNextDay, setPrevDay, setView }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const monthRef = useRef(MONTHS.map((m) => t(m)));
const [isToday, nowHours, nowMinutes] = useMemo(
() => [
date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear() && date.getDate() === now.getDate(),
now.getHours(),
now.getMinutes()
],
[date, now]
);
const _setView = useCallback(
(): void => setView(true),
[setView]
);
return (
<StyledDiv className={className}>
<h1>
<div>
<Button
className='all-events-button'
icon={'list'}
onClick={_setView}
/>
{date.getDate()} {monthRef.current[date.getMonth()]} {date.getFullYear()} {isToday && <DayTime />}
</div>
<Button.Group>
<Button
icon='chevron-left'
isDisabled={isToday}
onClick={setPrevDay}
/>
<Button
icon='chevron-right'
isDisabled={!hasNextDay}
onClick={setNextDay}
/>
</Button.Group>
</h1>
<div className='hoursContainer highlight--bg-faint'>
{HOURS.map((hour, index): React.ReactNode =>
<DayHour
date={date}
hour={hour}
index={index}
key={hour}
minutes={(isToday && nowHours === index) ? nowMinutes : 0}
scheduled={scheduled}
/>
)}
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
flex: 1;
.dayHeader {
align-items: center;
display: flex;
font-size: 1.25rem;
justify-content: space-between;
padding: 1rem 1.5rem 0;
}
.hoursContainer {
z-index: 1;
}
`;
export default React.memo(Day);
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { EntryInfoTyped } from './types.js';
import React, { useMemo } from 'react';
import { styled } from '@pezkuwi/react-components';
import DayItem from './DayItem.js';
interface Props {
className?: string;
date: Date;
hour: number;
index: number;
minutes: number;
scheduled: EntryInfoTyped[];
}
const MN_TO_MS = 60 * 1000;
const HR_TO_MS = 60 * MN_TO_MS;
function filterEntries (date: Date, minutes: number, index: number, scheduled: EntryInfoTyped[]): EntryInfoTyped[] {
const start = date.getTime() + (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));
}
function DayHour ({ className = '', date, hour, index, minutes, scheduled }: Props): React.ReactElement<Props> | null {
const filtered = useMemo(
() => filterEntries(date, minutes, index, scheduled),
[date, index, minutes, scheduled]
);
const hourStr = `${` ${hour}`.slice(-2)} ${hour >= 12 ? 'pm' : 'am'}`;
return (
<StyledDiv className={`${className}${filtered.length ? ' hasItems' : ''}`}>
<div className={`hourLabel${filtered.length ? ' highlight--color' : ''}`}>{hourStr}</div>
<div className='hourContainer'>
{filtered.map((item, index): React.ReactNode => (
<DayItem
item={item}
key={index}
/>
))}
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
align-items: center;
display: flex;
position: relative;
z-index: 2;
&:nth-child(odd) {
background: var(--bg-table);
}
&.isPast {
opacity: 0.75;
}
.hourContainer {
flex: 1;
padding: 0.25rem 0;
}
.hourLabel {
flex: 0;
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
line-height: 1;
min-width: 5.5rem;
opacity: var(--opacity-light);
padding: 0.5rem 1rem;
text-align: right;
text-transform: uppercase;
z-index: 1;
}
&.hasItems .hourLabel {
font-size: 1.1rem;
font-weight: var(--font-weight-normal);
opacity: 1;
padding: 0.7rem 1rem;
}
`;
export default React.memo(DayHour);
+250
View File
@@ -0,0 +1,250 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { EntryInfoTyped } from './types.js';
import React, { useCallback, useMemo, useState } from 'react';
import { Button, styled } from '@pezkuwi/react-components';
import { formatNumber, isString } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
import { dateCalendarFormat } from './util.js';
interface Props {
className?: string;
showAllEvents?: boolean;
item: EntryInfoTyped;
}
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'long',
weekday: 'long',
year: 'numeric'
};
function assertUnreachable (type: string): never {
throw new Error(`We should not get here, unhandled ${type}`);
}
function exportCalendar (date: Date, description: string): void {
const startDate = dateCalendarFormat(date);
// For now just add 1 hour for each event
const endDate = dateCalendarFormat(new Date(new Date(date).setHours(new Date(date).getHours() + 1)));
const calData =
'BEGIN:VCALENDAR\n' +
'CALSCALE:GREGORIAN\n' +
'METHOD:PUBLISH\n' +
'PRODID:-//Test Cal//EN\n' +
'VERSION:2.0\n' +
'BEGIN:VEVENT\n' +
'UID:test-1\n' +
'DTSTART;VALUE=DATE:' + startDate + '\n' +
'DTEND;VALUE=DATE:' + endDate + '\n' +
'SUMMARY:' + description + '\n' +
'DESCRIPTION:' + description + '\n' +
'END:VEVENT\n' +
'END:VCALENDAR';
const fileNameIcs = encodeURI(description) + '.ics';
const data = new File([calData], fileNameIcs, { type: 'text/plain' });
const anchor = window.document.createElement('a');
anchor.href = window.URL.createObjectURL(data);
anchor.download = fileNameIcs;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(anchor.href);
}
function createLink (to: string, desc: string): React.ReactNode {
return <div className='itemLink'><a href={`#/${to}`}>{desc}</a></div>;
}
function DayItem ({ className, item: { blockNumber, date, info, type }, showAllEvents }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [description, setDescription] = useState<string>('');
const _exportCal = useCallback(
() => exportCalendar(date, description),
[description, date]
);
const desc = useMemo(
(): React.ReactNode => {
const id: string | null = info && (
isString(info)
? info
: formatNumber(info)
);
const typeLink = ['councilElection'].includes(type)
? createLink('council', t('via Council'))
: ['councilMotion'].includes(type)
? createLink('council/motions', t('via Council/Motions'))
: ['democracyDispatch', 'scheduler'].includes(type)
? createLink('democracy/dispatch', t('via Democracy/Dispatch'))
: ['democracyLaunch', 'referendumDispatch', 'referendumVote'].includes(type)
? createLink('/democracy', t('via Democracy'))
: ['societyChallenge', 'societyRotate'].includes(type)
? createLink('society', t('via Society'))
: ['stakingEpoch', 'stakingEra'].includes(type)
? createLink('staking', t('via Staking'))
: ['stakingSlash'].includes(type)
? createLink('staking/slashes', t('via Staking/Slashed'))
: ['treasurySpend'].includes(type)
? createLink('treasury', t('via Treasury'))
: ['teyrchainLease'].includes(type)
? createLink('teyrchains', t('via Teyrchains'))
: ['teyrchainAuction'].includes(type)
? createLink('teyrchains/auction', t('via Teyrchains/Auction'))
: undefined;
let s = '';
switch (type) {
case 'councilElection':
s = t('Election of new council candidates');
break;
case 'councilMotion':
s = t('Voting ends on council motion {{id}}', { replace: { id } });
break;
case 'democracyDispatch':
s = t('Enactment of the result of referendum {{id}}', { replace: { id } });
break;
case 'democracyLaunch':
s = t('Start of the next referendum voting period');
break;
case 'teyrchainAuction':
s = t('End of the current teyrchain auction {{id}}', { replace: { id } });
break;
case 'teyrchainLease':
s = t('Start of the next teyrchain lease period {{id}}', { replace: { id } });
break;
case 'referendumDispatch':
s = t('Potential dispatch of referendum {{id}} (if passed)', { replace: { id } });
break;
case 'referendumVote':
s = t('Voting ends for referendum {{id}}', { replace: { id } });
break;
case 'scheduler':
s = id
? t('Execute named scheduled task {{id}}', { replace: { id } })
: t('Execute anonymous scheduled task');
break;
case 'stakingEpoch':
s = t('Start of a new staking session {{id}}', { replace: { id } });
break;
case 'stakingEra':
s = t('Start of a new staking era {{id}}', { replace: { id } });
break;
case 'stakingSlash':
s = t('Application of slashes from era {{id}}', { replace: { id } });
break;
case 'treasurySpend':
s = t('Start of next spending period');
break;
case 'societyChallenge':
s = t('Start of next membership challenge period');
break;
case 'societyRotate':
s = t('Acceptance of new members and bids');
break;
default:
return assertUnreachable(type);
}
setDescription(s);
return (<><div className='itemDesc'>{s}</div>{typeLink}</>);
},
[info, t, type]
);
return (
<StyledDiv className={className}>
{showAllEvents &&
<div className='itemDate'>{date.toLocaleString(undefined, FORMAT_OPTIONS)}</div>
}
<div className='itemTime'>{date.toLocaleTimeString().split(':').slice(0, 2).join(':')}</div>
<div className='itemBlock'>#{formatNumber(blockNumber)}</div>
{desc}
{date && (
<Button
className={showAllEvents ? 'exportCal exportCal-allEvents' : 'exportCal'}
icon='calendar-plus'
onClick={_exportCal}
/>
)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
align-items: flex-start;
display: flex;
justify-content: flex-start;
margin: 0.5rem 0.75rem;
> div+div {
margin-left: 0.5rem;
}
> div.itemTime+div.itemBlock {
margin-left: 0.25rem;
}
.exportCal {
padding: 0;
position: absolute;
right: 1.5rem;
.ui--Icon {
width: 0.7rem;
height: 0.7rem;
}
}
.exportCal-allEvents {
right: 3.5rem;
}
.itemBlock {
background: #aaa;
color: #eee;
font-size: var(--font-size-small);
align-self: center;
padding: 0.075rem 0.375rem;
border-radius: 0.25rem;
}
.itemDate {
padding: 0 0.375rem;
border-radius: 0.25rem;
width: 17rem;
}
.itemTime {
background: #999;
color: #eee;
padding: 0 0.375rem;
border-radius: 0.25rem;
}
`;
export default React.memo(DayItem);
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';
function DayTime (): React.ReactElement {
const [now, setNow] = useState(new Date());
useEffect((): () => void => {
const intervalId = setInterval(() => setNow(new Date()), 1000);
return (): void => {
clearInterval(intervalId);
};
}, []);
return <>{now.toLocaleTimeString().split(':').slice(0, 2).join(':')}</>;
}
export default React.memo(DayTime);
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DateState, EntryInfo } from './types.js';
import React, { useMemo, useRef } from 'react';
import { Button, styled } from '@pezkuwi/react-components';
import { DAYS, MONTHS } from './constants.js';
import MonthDay from './MonthDay.js';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
hasNextMonth: boolean;
lastDay: number;
now: Date;
scheduled: EntryInfo[];
setDay: (day: number) => void;
setNextMonth: () => void;
setPrevMonth: () => void;
state: DateState;
}
function Month ({ className, hasNextMonth, lastDay, now, scheduled, setDay, setNextMonth, setPrevMonth, state: { dateMonth, dateSelected, days, startClass } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const dayOfWeekRef = useRef(DAYS.map((d) => t(d)));
const monthRef = useRef(MONTHS.map((m) => t(m)));
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 (
<StyledDiv className={className}>
<h1>
<div>{monthRef.current[dateMonth.getMonth()]} {dateMonth.getFullYear()}</div>
<Button.Group>
<Button
icon='chevron-left'
isDisabled={isNowYear && (isOlderMonth || isNowMonth)}
onClick={setPrevMonth}
/>
<Button
icon='chevron-right'
isDisabled={!hasNextMonth}
onClick={setNextMonth}
/>
</Button.Group>
</h1>
<div className={`calendar ${startClass}`}>
<div className='dayOfWeek'>
{dayOfWeekRef.current.map((day): React.ReactNode => (
<div key={day}>{t(day)}</div>
))}
</div>
<div className='dateGrid'>
{days.map((day): React.ReactNode => (
<MonthDay
dateMonth={dateMonth}
day={day}
isCurrent={isCurrYear && isCurrMonth && day === dateSelected.getDate()}
isDisabled={(isNowYear && (isOlderMonth || (isNowMonth && now.getDate() > day))) || (!hasNextMonth && day > lastDay)}
key={day}
scheduled={scheduled}
setDay={setDay}
/>
))}
</div>
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
flex: 0;
max-width: max-content;
.calendar {
padding: 1rem 1.5rem;
.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 {
> * {
font-size: var(--font-size-tiny);
font-weight: var(--font-weight-normal);
letter-spacing: 0.1em;
text-align: center;
text-transform: uppercase;
}
}
.monthIndicator {
align-items: center;
display: flex;
font-size: 1.25rem;
justify-content: space-between;
}
}
`;
export default React.memo(Month);
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { EntryInfo } from './types.js';
import React, { useCallback, useMemo } from 'react';
import { styled } from '@pezkuwi/react-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<Props> {
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 (
<StyledDiv
className={`${className} day ${isDisabled ? 'isDisabled' : (isCurrent ? 'highlight--bg-light highlight--color isSelected' : '')}`}
onClick={_onClick}
>
{day}
{hasEvents && <div className='eventIndicator highlight--border' />}
</StyledDiv>
);
}
const StyledDiv = styled.div`
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;
}
}
`;
export default React.memo(MonthDay);
@@ -0,0 +1,76 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { EntryInfoTyped } from './types.js';
import React, { useCallback, useMemo } from 'react';
import { Button, styled } from '@pezkuwi/react-components';
import DayItem from './DayItem.js';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
scheduled: EntryInfoTyped[];
setView: (v: boolean) => void;
}
function UpcomingEvents ({ className, scheduled, setView }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const sched = useMemo(
() => scheduled.sort((a, b) => a.dateTime - b.dateTime),
[scheduled]
);
const _setView = useCallback(
(): void => setView(false),
[setView]
);
return (
<StyledDiv className={className}>
<h1>
<div>
<Button
className='all-events-button'
icon='calendar'
onClick={_setView}
/>
{t('Upcoming Events')}
</div>
</h1>
<ul className='allEventsWrapper'>
{sched.map((item, index): React.ReactNode => {
return (
<DayItem
className='all-events-rows'
item={item}
key={index}
showAllEvents
/>
);
})}
</ul>
</StyledDiv>
);
}
const StyledDiv = styled.div`
flex: 0;
max-width: max-content;
.all-events-rows {
padding: 10px 0;
font-size: 13px;
&:nth-child(odd) {
background: var(--bg-table);
}
}
.allEventsWrapper {
padding-inline-start: 10px;
}
`;
export default React.memo(UpcomingEvents);
+5
View File
@@ -0,0 +1,5 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
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'];
+212
View File
@@ -0,0 +1,212 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DateState } from './types.js';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { styled, Tabs } from '@pezkuwi/react-components';
import Day from './Day.js';
import Month from './Month.js';
import { useTranslation } from './translate.js';
import UpcomingEvents from './UpcomingEvents.js';
import useScheduled from './useScheduled.js';
import { getDateState, nextMonth, prevMonth } from './util.js';
interface Props {
basePath: string;
className?: string;
}
const NOW_INC = 30 * 1000;
function CalendarApp ({ basePath, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const scheduled = useScheduled();
const [now, setNow] = useState(() => new Date());
const [dateState, setDateState] = useState(() => getDateState(now, now));
const [allEventsView, setAllEventsView] = useState(false);
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);
};
}, []);
const [hasNextMonth, hasNextDay, lastDay] = useMemo(
() => {
const nextDay = new Date(dateState.dateSelected);
nextDay.setDate(nextDay.getDate() + 1);
const nextDayTime = nextDay.getTime();
const nextMonthTime = dateState.dateMonthNext.getTime();
const hasNextMonth = scheduled.some(({ dateTime }) => dateTime >= nextMonthTime);
const hasNextDay = hasNextMonth || scheduled.some(({ dateTime }) => dateTime >= nextDayTime);
const lastDay = hasNextMonth
? 42 // more than days in month
: scheduled.reduce((lastDay: number, { date }): number => {
return date.getFullYear() === dateState.dateMonth.getFullYear() && date.getMonth() === dateState.dateMonth.getMonth()
? Math.max(lastDay, date.getDate())
: lastDay;
}, 0);
return [hasNextMonth, hasNextDay, lastDay];
},
[dateState, scheduled]
);
const _nextMonth = useCallback(
() => setDateState(({ dateMonth, dateSelected }) => getDateState(nextMonth(dateMonth), dateSelected)),
[]
);
const _prevMonth = useCallback(
() => setDateState(({ dateMonth, dateSelected }) => getDateState(prevMonth(dateMonth), dateSelected)),
[]
);
const _nextDay = useCallback(
() => setDateState(({ dateSelected }): DateState => {
const date = new Date(dateSelected);
date.setDate(date.getDate() + 1);
return getDateState(date, date);
}),
[]
);
const _prevDay = useCallback(
() => setDateState(({ dateSelected }): DateState => {
const date = new Date(dateSelected);
date.setDate(date.getDate() - 1);
return getDateState(date, date);
}),
[]
);
const _setDay = useCallback(
(day: number) => setDateState(({ dateMonth }): DateState => {
const date = new Date(dateMonth);
date.setDate(day);
return getDateState(date, date);
}),
[]
);
const _setAllEventsView = useCallback(
(v: boolean) => setAllEventsView(v),
[]
);
return (
<StyledMain className={className}>
<Tabs
basePath={basePath}
items={itemsRef.current}
/>
<div className='calendarFlex'>
<Month
hasNextMonth={hasNextMonth}
lastDay={lastDay}
now={now}
scheduled={scheduled}
setDay={_setDay}
setNextMonth={_nextMonth}
setPrevMonth={_prevMonth}
state={dateState}
/>
<div className='wrapper-style'>
{allEventsView
? (
<UpcomingEvents
className='upcoming-events'
scheduled={scheduled}
setView={_setAllEventsView}
/>
)
: (
<Day
date={dateState.dateSelected}
hasNextDay={hasNextDay}
now={now}
scheduled={scheduled}
setNextDay={_nextDay}
setPrevDay={_prevDay}
setView={_setAllEventsView}
/>
)
}
</div>
</div>
</StyledMain>
);
}
const StyledMain = styled.main`
.calendarFlex {
align-items: flex-start;
display: flex;
flex-wrap: nowrap;
.wrapper-style {
flex: 1;
.upcoming-events {
position: relative;
max-width: 100%;
}
}
> div {
background-color: var(--bg-table);
border: 1px solid var(--border-table);
border-radius: 0.25rem;
&+div {
margin-left: 1.5rem;
}
.ui--Button-Group {
margin: 0;
}
}
h1 {
align-items: center;
border-bottom: 0.25rem solid var(--bg-page);
display: flex;
justify-content: space-between;
padding: 0.5rem 0.5rem 0.5rem 1rem;
> div:first-child {
align-items: center;
display: inline-flex;
}
.all-events-button {
margin-right: 1rem;
}
.ui--Button {
font-size: var(--font-size-small);
}
}
}
`;
export default React.memo(CalendarApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-calendar');
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
export type EntryType = 'councilElection' | 'councilMotion' | 'democracyDispatch' | 'democracyLaunch' | 'teyrchainAuction' | 'teyrchainLease' | 'referendumDispatch' | 'referendumVote' | 'scheduler' | 'societyChallenge' | 'societyRotate'| 'stakingEpoch' | 'stakingEra' | 'stakingSlash' | 'treasurySpend';
export interface EntryInfo {
blockNumber: BN;
blocks: BN;
date: Date;
dateTime: number;
info: string | BN | null;
isPending?: boolean;
}
export interface EntryInfoTyped extends EntryInfo {
type: EntryType;
}
export interface DateState {
dateMonth: Date;
dateMonthNext: Date;
dateSelected: Date;
days: number[];
startClass: string;
}
+266
View File
@@ -0,0 +1,266 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal, DeriveDispatch, DeriveReferendumExt, DeriveSessionProgress } from '@pezkuwi/api-derive/types';
import type { Option } from '@pezkuwi/types';
import type { BlockNumber, EraIndex, LeasePeriodOf, Scheduled, UnappliedSlash } from '@pezkuwi/types/interfaces';
import type { ITuple } from '@pezkuwi/types/types';
import type { BN } from '@pezkuwi/util';
import type { EntryInfo, EntryInfoTyped, EntryType } from './types.js';
import { useEffect, useState } from 'react';
import { useLeaseRangeMax } from '@pezkuwi/app-teyrchains/useLeaseRanges';
import { createNamedHook, useApi, useBestNumber, useBlockInterval, useCall } from '@pezkuwi/react-hooks';
import { BN_ONE, BN_ZERO } from '@pezkuwi/util';
interface DateExt {
date: Date;
dateTime: number;
}
type SlashEntry = [{ args: [EraIndex] }, UnappliedSlash[]];
type ScheduleEntry = [{ args: [BlockNumber] }, Option<Scheduled>[]];
function newDate (blocks: BN, blockTime: BN): DateExt {
const date = new Date(Date.now() + blocks.mul(blockTime).toNumber());
return { date, dateTime: date.getTime() };
}
function createConstDurations (bestNumber: BlockNumber, blockTime: BN, items: [EntryType, BlockNumber?, BN?, BN?][]): [EntryType, EntryInfo[]][] {
return items.map(([type, duration, additional = BN_ZERO, offset = BN_ZERO]): [EntryType, EntryInfo[]] => {
if (!duration) {
return [type, []];
}
const startNumber = bestNumber.sub(offset);
const blocks = duration.sub(startNumber.mod(duration));
return [type, [{
...newDate(blocks, blockTime),
blockNumber: startNumber.add(blocks),
blocks,
info: startNumber.div(duration).iadd(additional)
}]];
});
}
function createCouncilMotions (bestNumber: BlockNumber, blockTime: BN, motions: DeriveCollectiveProposal[]): [EntryType, EntryInfo[]][] {
return [['councilMotion', motions
.map(({ hash, votes }): EntryInfo | null => {
if (!votes) {
return null;
}
const hashStr = hash.toHex();
const blocks = votes.end.sub(bestNumber);
return {
...newDate(blocks, blockTime),
blockNumber: votes.end,
blocks,
info: `${hashStr.slice(0, 6)}${hashStr.slice(-4)}`
};
})
.filter((item): item is EntryInfo => !!item)
]];
}
function createDispatches (bestNumber: BlockNumber, blockTime: BN, 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
}]];
});
}
function createReferendums (bestNumber: BlockNumber, blockTime: BN, referendums: DeriveReferendumExt[]): [EntryType, EntryInfo[]][] {
return referendums.reduce((result: [EntryType, EntryInfo[]][], { index, status }): [EntryType, EntryInfo[]][] => {
const enactBlocks = status.end.add(status.delay).isub(bestNumber);
const voteBlocks = status.end.sub(bestNumber).isub(BN_ONE);
result.push(['referendumVote', [{
...newDate(voteBlocks, blockTime),
blockNumber: bestNumber.add(voteBlocks),
blocks: voteBlocks,
info: index
}]]);
result.push(['referendumDispatch', [{
...newDate(enactBlocks, blockTime),
blockNumber: bestNumber.add(enactBlocks),
blocks: enactBlocks,
info: index,
isPending: true
}]]);
return result;
}, []);
}
function createStakingInfo (bestNumber: BlockNumber, blockTime: BN, sessionInfo: DeriveSessionProgress, unapplied: SlashEntry[], slashDeferDuration?: BlockNumber): [EntryType, EntryInfo[]][] {
const blocksEra = sessionInfo.eraLength.sub(sessionInfo.eraProgress);
const blocksSes = 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).isub(BN_ONE).imul(sessionInfo.eraLength).iadd(sessionInfo.eraProgress);
const blocks = slashDuration.sub(blockProgress);
return {
...newDate(blocks, blockTime),
blockNumber: bestNumber.add(blocks),
blocks,
info: eraIndex
};
})
: [];
return [
['stakingEpoch', [{
...newDate(blocksSes, blockTime),
blockNumber: bestNumber.add(blocksSes),
blocks: blocksSes,
info: sessionInfo.currentIndex.add(BN_ONE)
}]],
['stakingEra', [{
...newDate(blocksEra, blockTime),
blockNumber: bestNumber.add(blocksEra),
blocks: blocksEra,
info: sessionInfo.activeEra.add(BN_ONE)
}]],
['stakingSlash', slashEras]
];
}
function createScheduled (bestNumber: BlockNumber, blockTime: BN, 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
});
return items;
}, items);
}, [])]];
}
function createAuctionInfo (bestNumber: BlockNumber, blockTime: BN, rangeMax: BN, [leasePeriod, endBlock]: [LeasePeriodOf, BlockNumber]): [EntryType, EntryInfo[]][] {
const blocks = endBlock.sub(bestNumber);
return [
['teyrchainAuction', [{
...newDate(blocks, blockTime),
blockNumber: endBlock,
blocks,
info: `${leasePeriod.toString()} - ${leasePeriod.add(rangeMax).toString()}`
}]]
];
}
function addFiltered (state: EntryInfoTyped[], types: [EntryType, EntryInfo[]][]): EntryInfoTyped[] {
return types.reduce((state: EntryInfoTyped[], [typeFilter, items]): EntryInfoTyped[] => {
return state
.filter(({ type }) => type !== typeFilter)
.concat(...items.map((item): EntryInfoTyped => {
(item as EntryInfoTyped).type = typeFilter;
return item as EntryInfoTyped;
}));
}, state);
}
// TODO council votes, tips closing
function useScheduledImpl (): EntryInfoTyped[] {
const { api } = useApi();
const blockTime = useBlockInterval();
const bestNumber = useBestNumber();
const leaseRangeMax = useLeaseRangeMax();
const auctionInfo = useCall<Option<ITuple<[LeasePeriodOf, BlockNumber]>>>(api.query.auctions?.auctionInfo);
const councilMotions = useCall<DeriveCollectiveProposal[]>(api.derive.council?.proposals);
const dispatches = useCall<DeriveDispatch[]>(api.derive.democracy?.dispatchQueue);
const referendums = useCall<DeriveReferendumExt[]>(api.derive.democracy?.referendums);
const scheduled = useCall<ScheduleEntry[]>(api.query.scheduler?.agenda?.entries);
const sessionInfo = useCall<DeriveSessionProgress>(api.derive.session?.progress);
const slashes = useCall<SlashEntry[]>(api.query.staking?.unappliedSlashes.entries);
const [state, setState] = useState<EntryInfoTyped[]>([]);
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) && setState((state) =>
addFiltered(state, createStakingInfo(bestNumber, blockTime, sessionInfo, slashes || [], api.consts.staking?.slashDeferDuration))
);
}, [api, bestNumber, blockTime, sessionInfo, slashes]);
useEffect((): void => {
bestNumber && auctionInfo?.isSome && setState((state) =>
addFiltered(state, createAuctionInfo(bestNumber, blockTime, leaseRangeMax, auctionInfo.unwrap()))
);
}, [auctionInfo, bestNumber, blockTime, leaseRangeMax]);
useEffect((): void => {
bestNumber && setState((state) =>
addFiltered(state, createConstDurations(bestNumber, blockTime, [
['councilElection', (api.consts.elections || api.consts.phragmenElection || api.consts.electionsPhragmen)?.termDuration],
['democracyLaunch', api.consts.democracy?.launchPeriod],
['teyrchainLease', api.consts.slots?.leasePeriod as BlockNumber, BN_ONE, api.consts.slots?.leaseOffset as BlockNumber],
['societyChallenge', api.consts.society?.challengePeriod],
['societyRotate', api.consts.society?.rotationPeriod as BlockNumber],
['treasurySpend', api.consts.treasury?.spendPeriod]
]))
);
}, [api, bestNumber, blockTime]);
return state;
}
export default createNamedHook('useScheduled', useScheduledImpl);
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2017-2025 @pezkuwi/app-calendar authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DateState } from './types.js';
import { DAYS } from './constants.js';
export function newZeroDate (input: Date): Date {
const date = new Date(input);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
export 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);
}
export 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);
}
export function getDateState (_dateMonth: Date, _dateSelected: Date): DateState {
const dateMonth = newZeroDate(_dateMonth);
dateMonth.setDate(1);
const dateMonthNext = nextMonth(dateMonth);
const dateSelected = newZeroDate(_dateSelected);
const numDays = nextMonth(dateMonth, 0).getDate();
const days: number[] = [];
for (let i = 1; i <= numDays; i++) {
days.push(i);
}
return {
dateMonth,
dateMonthNext,
dateSelected,
days,
startClass: `start${DAYS[dateMonth.getDay()]}`
};
}
export function dateCalendarFormat (date: Date): string {
return new Date(date)
.toISOString()
.split('.')[0]
.replace(/-/g, '')
.replace(/:/g, '') + 'Z';
}