From c38a04e02f1932eb422d6e9004bd4e9a7a6a6899 Mon Sep 17 00:00:00 2001 From: bakhirev Date: Wed, 18 Jun 2025 15:42:38 +0300 Subject: [PATCH] update --- public/assets/chart/person.svg | 1 + public/assets/chart/person_add.svg | 1 + public/assets/chart/person_add_remove.svg | 1 + public/assets/chart/person_remove.svg | 1 + public/assets/chart/release.svg | 1 + public/assets/chart/tasks.svg | 1 + .../DayInfo/components/CommitInfo.tsx | 33 +++++ .../DayInfo/components/TaskInfo.tsx | 42 ++++++ src/ts/components/DayInfo/index.tsx | 81 +++--------- .../components/YearChart/components/Body.tsx | 37 +++--- .../components/YearChart/components/Day.tsx | 122 +++++++++++------- .../YearChart/components/DayInfo.tsx | 67 ++++++++++ .../YearChart/components/Header.tsx | 15 ++- .../components/YearChart/components/Month.tsx | 76 +++++------ src/ts/components/YearChart/helpers/day.ts | 92 ++++++------- src/ts/components/YearChart/helpers/events.ts | 58 +++++++++ src/ts/components/YearChart/index.tsx | 92 +++++-------- .../YearChart/interfaces/Filters.ts | 6 + src/ts/components/YearChart/store/DayInfo.ts | 40 ++++++ .../YearChart/styles/day_info.module.scss | 83 ++++++++++++ .../YearChart/styles/index.module.scss | 22 ++++ .../components/YearChart2/components/Body.tsx | 67 ++++++++++ .../components/YearChart2/components/Day.tsx | 71 ++++++++++ .../YearChart2/components/Header.tsx | 30 +++++ .../YearChart2/components/Month.tsx | 60 +++++++++ src/ts/components/YearChart2/helpers/day.ts | 71 ++++++++++ .../helpers/getAuthorByDate.ts | 0 .../helpers/getCommitsByMonth.ts | 0 src/ts/components/YearChart2/index.tsx | 97 ++++++++++++++ .../interfaces/Month.ts | 0 .../interfaces/WorkDay.ts | 0 .../YearChart2/styles/index.module.scss | 97 ++++++++++++++ .../YearChart2/styles/line.module.scss | 24 ++++ src/ts/helpers/DataGrip/components/month.ts | 119 +++++++++++++++++ src/ts/helpers/DataGrip/index.ts | 8 +- src/ts/helpers/formatter.ts | 2 +- src/ts/helpers/mouseCoordinates.ts | 101 +++++++++++++++ src/ts/pages/Common/components/Changes.tsx | 5 +- src/ts/pages/Common/components/Commits.tsx | 6 +- src/ts/pages/Person/components/Month.tsx | 2 +- .../Country/styles/index.module.scss | 2 + .../pages/Team/components/Month/Filters.tsx | 72 +++++++++++ .../components/{Month.tsx => Month/index.tsx} | 31 +++-- src/ts/translations/de/pages.ts | 2 + src/ts/translations/en/pages.ts | 2 + src/ts/translations/es/pages.ts | 2 + src/ts/translations/fr/pages.ts | 2 + src/ts/translations/ja/pages.ts | 2 + src/ts/translations/ko/pages.ts | 2 + src/ts/translations/pt/pages.ts | 2 + src/ts/translations/ru/pages.ts | 2 + src/ts/translations/src/output/pages.ts | 2 + src/ts/translations/zh/pages.ts | 2 + 53 files changed, 1453 insertions(+), 304 deletions(-) create mode 100644 public/assets/chart/person.svg create mode 100644 public/assets/chart/person_add.svg create mode 100644 public/assets/chart/person_add_remove.svg create mode 100644 public/assets/chart/person_remove.svg create mode 100644 public/assets/chart/release.svg create mode 100644 public/assets/chart/tasks.svg create mode 100644 src/ts/components/DayInfo/components/CommitInfo.tsx create mode 100644 src/ts/components/DayInfo/components/TaskInfo.tsx create mode 100644 src/ts/components/YearChart/components/DayInfo.tsx create mode 100644 src/ts/components/YearChart/helpers/events.ts create mode 100644 src/ts/components/YearChart/interfaces/Filters.ts create mode 100644 src/ts/components/YearChart/store/DayInfo.ts create mode 100644 src/ts/components/YearChart/styles/day_info.module.scss create mode 100644 src/ts/components/YearChart2/components/Body.tsx create mode 100644 src/ts/components/YearChart2/components/Day.tsx create mode 100644 src/ts/components/YearChart2/components/Header.tsx create mode 100644 src/ts/components/YearChart2/components/Month.tsx create mode 100644 src/ts/components/YearChart2/helpers/day.ts rename src/ts/components/{YearChart => YearChart2}/helpers/getAuthorByDate.ts (100%) rename src/ts/components/{YearChart => YearChart2}/helpers/getCommitsByMonth.ts (100%) create mode 100644 src/ts/components/YearChart2/index.tsx rename src/ts/components/{YearChart => YearChart2}/interfaces/Month.ts (100%) rename src/ts/components/{YearChart => YearChart2}/interfaces/WorkDay.ts (100%) create mode 100644 src/ts/components/YearChart2/styles/index.module.scss create mode 100644 src/ts/components/YearChart2/styles/line.module.scss create mode 100644 src/ts/helpers/DataGrip/components/month.ts create mode 100644 src/ts/helpers/mouseCoordinates.ts create mode 100644 src/ts/pages/Team/components/Month/Filters.tsx rename src/ts/pages/Team/components/{Month.tsx => Month/index.tsx} (50%) diff --git a/public/assets/chart/person.svg b/public/assets/chart/person.svg new file mode 100644 index 0000000..593574a --- /dev/null +++ b/public/assets/chart/person.svg @@ -0,0 +1 @@ + diff --git a/public/assets/chart/person_add.svg b/public/assets/chart/person_add.svg new file mode 100644 index 0000000..a20a5ff --- /dev/null +++ b/public/assets/chart/person_add.svg @@ -0,0 +1 @@ + diff --git a/public/assets/chart/person_add_remove.svg b/public/assets/chart/person_add_remove.svg new file mode 100644 index 0000000..11aa919 --- /dev/null +++ b/public/assets/chart/person_add_remove.svg @@ -0,0 +1 @@ + diff --git a/public/assets/chart/person_remove.svg b/public/assets/chart/person_remove.svg new file mode 100644 index 0000000..4fa7027 --- /dev/null +++ b/public/assets/chart/person_remove.svg @@ -0,0 +1 @@ + diff --git a/public/assets/chart/release.svg b/public/assets/chart/release.svg new file mode 100644 index 0000000..02375a9 --- /dev/null +++ b/public/assets/chart/release.svg @@ -0,0 +1 @@ + diff --git a/public/assets/chart/tasks.svg b/public/assets/chart/tasks.svg new file mode 100644 index 0000000..8b926b9 --- /dev/null +++ b/public/assets/chart/tasks.svg @@ -0,0 +1 @@ + diff --git a/src/ts/components/DayInfo/components/CommitInfo.tsx b/src/ts/components/DayInfo/components/CommitInfo.tsx new file mode 100644 index 0000000..5c94565 --- /dev/null +++ b/src/ts/components/DayInfo/components/CommitInfo.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { getShortTime } from 'ts/helpers/formatter'; + +import style from '../index.module.scss'; + +interface ICommit { + date: string; + message: string; +} + +interface CommitInfoProps { + commits: ICommit[]; +} + +export default function CommitInfo({ commits }: CommitInfoProps): React.ReactElement { + const items = commits.map((commit: any) => { + return ( +
+ + {getShortTime(commit.date)} + + + {commit.message} + +
+ ); + }); + return (<>{items}); +} diff --git a/src/ts/components/DayInfo/components/TaskInfo.tsx b/src/ts/components/DayInfo/components/TaskInfo.tsx new file mode 100644 index 0000000..301dda3 --- /dev/null +++ b/src/ts/components/DayInfo/components/TaskInfo.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import IHashMap from 'ts/interfaces/HashMap'; +import { TaskLink, PRLink } from 'ts/components/ExternalLink'; +import dataGrip from 'ts/helpers/DataGrip'; + +import CommitInfo from './CommitInfo'; + +import style from '../index.module.scss'; + +interface ICommit { + date: string; + message: string; +} + +interface TaskInfoProps { + tasks: IHashMap; +} + +export default function TaskInfo({ tasks }: TaskInfoProps): React.ReactElement { + const items = Object.entries(tasks) + .map(([task, commits]: [string, any]) => { + const taskInfo = dataGrip.tasks.statisticByName.get(task); + const milliseconds = commits[0].milliseconds; + const prId = taskInfo?.prIds?.find((id: string) => { + const pr = dataGrip.pr.pr.get(id); + return pr.dateMerge >= milliseconds; + }); + + return ( +
+
+ + +
+ +
+ ); + }); + + return (<>{items}); +} diff --git a/src/ts/components/DayInfo/index.tsx b/src/ts/components/DayInfo/index.tsx index 2ed04e1..4cae379 100644 --- a/src/ts/components/DayInfo/index.tsx +++ b/src/ts/components/DayInfo/index.tsx @@ -1,84 +1,40 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import IHashMap from 'ts/interfaces/HashMap'; -import { TaskLink, PRLink } from 'ts/components/ExternalLink'; -import { getShortTime } from 'ts/helpers/formatter'; import dataGrip from 'ts/helpers/DataGrip'; +import TaskInfo from './components/TaskInfo'; + import style from './index.module.scss'; -interface ICommit { - date: string; - message: string; +interface DayEvent { + firstDay: Set | undefined; + lastDay: Set | undefined; + release: Set | undefined; } -type ITask = IHashMap; - -type IDayInfo = IHashMap; - -function CommitInfo({ commits }: { commits: ICommit[] }): React.ReactElement { - const items = commits.map((commit: any) => { - return ( -
- - {getShortTime(commit.date)} - - - {commit.message} - -
- ); - }); - return (<>{items}); +interface DayInfoProps { + timestamp: string; + events?: DayEvent; } -function TaskInfo({ tasks }: { tasks: ITask }): React.ReactElement { - const items = Object.entries(tasks) - .map(([task, commits]: [string, any]) => { - const taskInfo = dataGrip.tasks.statisticByName.get(task); - const milliseconds = commits[0].milliseconds; - const prId = taskInfo?.prIds?.find((id: string) => { - const pr = dataGrip.pr.pr.get(id); - return pr.dateMerge >= milliseconds; - }); - return ( -
-
- - -
- -
- ); - }); - return (<>{items}); -} - -interface IDayInfoProps { - day: IDayInfo; - order: string[]; - events?: any; - timestamp?: string; -} - -function DayInfo({ day, order, events, timestamp }: IDayInfoProps): React.ReactElement { +function DayInfo({ timestamp, events }: DayInfoProps): React.ReactElement { const { t } = useTranslation(); - const firstCommit = events?.firstCommit?.[timestamp || ''] || []; - const lastCommit = events?.lastCommit?.[timestamp || ''] || []; let taskNumber = 0; - const items = Object.entries(day?.tasksByAuthor) + const allCommitsByTimestamp = dataGrip.timestamp.statistic.allCommitsByTimestamp; + const commitsByTimestamp = allCommitsByTimestamp.find((item: any) => item.timestamp === timestamp); + const tasksByAuthor = commitsByTimestamp.tasksByAuthor || {}; + const order = dataGrip.author.list; + + const items = Object.entries(tasksByAuthor) .sort((a: any, b: any) => (order.indexOf(a[0]) - order.indexOf(b[0]))) .map(([author, tasks]: [string, any]) => { taskNumber += Object.keys(tasks).length; let suffix = ''; - if (firstCommit.includes(author)) suffix = t('page.team.month.first'); - if (lastCommit.includes(author)) suffix = t('page.team.month.last'); + if (events?.firstDay?.has(author)) suffix = t('page.team.month.first'); + if (events?.lastDay?.has(author)) suffix = t('page.team.month.last'); return (
{ - const dayInfo = month.commits[currentDay]; + const dayInMonth = index - firstDay + 1; + const dayInfo = month.days[currentDay]; + const eventsByDay = events.get(dayInfo?.timestamp); - if (dayInfo?.dayInMonth === (index - firstDay + 1)) { + if (dayInfo?.dayInMonth === dayInMonth) { currentDay += 1; return ( ); } diff --git a/src/ts/components/YearChart/components/Day.tsx b/src/ts/components/YearChart/components/Day.tsx index f42181e..58e468e 100644 --- a/src/ts/components/YearChart/components/Day.tsx +++ b/src/ts/components/YearChart/components/Day.tsx @@ -1,71 +1,95 @@ -import React, { useState } from 'react'; - -import IHashMap from 'ts/interfaces/HashMap'; -import dataGripStore from 'ts/store/DataGrip'; -import DayInfo from 'ts/components/DayInfo'; -import Title from 'ts/components/Title'; +import React from 'react'; import { getDate } from 'ts/helpers/formatter'; +import { DataGripDay } from 'ts/helpers/DataGrip/components/month'; + +import { Filters } from '../interfaces/Filters'; +import { getPercentByMax, getColor, COLORS } from '../helpers/day'; +import { DayEvent } from '../helpers/events'; +import dayInfoStore from '../store/DayInfo'; -import IMonth from '../interfaces/Month'; import style from '../styles/index.module.scss'; -import { - getPercentByMax, - getColor, - getIconUrl, getDayText, -} from '../helpers/day'; +interface DayProps { + max: number; + dayNumber: number; + dayInfo: DataGripDay; + events?: DayEvent; + filters: Filters; +} -interface IDayProps { - maxCommits: number; - dayNumber: any; - month: IMonth; - dayInfo: any; - events: IHashMap; +function DayIcon({ src }: { src: string }) { + return ( + + ); +} + +function getText(filters: Filters, events?: DayEvent) { + if (filters.firstLastDays) { + if (events?.firstDay && !events?.lastDay) { + return (); + } + if (!events?.firstDay && events?.lastDay) { + return (); + } + if (events?.firstDay && events?.lastDay) { + return (); + } + } + if (filters.release && events?.release) { + return (); + } + return ' '; +} + +function getColorList(dayNumber: number, filters: Filters, dayInfo: DataGripDay) { + // @ts-ignore + const author = filters?.authors?.[0]?.title; + if (author && dayInfo.userCommitNumbers.has(author)) { + return COLORS.SELECTED; + } + // @ts-ignore + const type = filters?.types?.[0]?.title; + if (type && dayInfo.typeCommitNumbers.has(type)) { + return COLORS.SELECTED; + } + const weekend = [5, 6, 12, 13, 19, 20, 26, 27, 33, 34, 40, 41]; + if (weekend.includes(dayNumber)) { + return COLORS.WEEKEND; + } + return COLORS.WORKS; } function Day({ - month, - dayInfo, - maxCommits, + max, dayNumber, events, -}: IDayProps): React.ReactElement | null { - const [showInfo, setShowInfo] = useState(false); - const weekend = [5, 6, 12, 13, 19, 20, 26, 27, 33, 34, 40, 41]; - const opacity = getPercentByMax(dayInfo.commits, maxCommits); - const isWeekend = weekend.includes(dayNumber); - const backgroundColor = getColor(isWeekend, opacity); - const iconUrl = getIconUrl(month, dayInfo.dayInMonth); - const text = getDayText(events, dayInfo.timestamp); + filters, + dayInfo, +}: DayProps): React.ReactElement | null { + const opacity = getPercentByMax(dayInfo.commitsNumber, max); + const colorList = getColorList(dayNumber, filters, dayInfo); + const backgroundColor = getColor(colorList, opacity); - return ( // @ts-ignore + const title = getDate(dayInfo.timestamp); + const text = getText(filters, events); + + return (
{ - setShowInfo(!showInfo); + onClick={(event) => { + dayInfoStore.toggle(dayInfo, [event.pageX, event.pageY]); }} > - {showInfo ? ( - <> - {'◉'} -
-
- - <DayInfo // @ts-ignore - day={dayInfo} - events={events} - timestamp={dayInfo.timestamp} - order={dataGripStore.dataGrip.author.list} - /> - </div> - </> - ) : text} + {text || ' '} </div> ); } diff --git a/src/ts/components/YearChart/components/DayInfo.tsx b/src/ts/components/YearChart/components/DayInfo.tsx new file mode 100644 index 0000000..108f0f5 --- /dev/null +++ b/src/ts/components/YearChart/components/DayInfo.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { observer } from 'mobx-react-lite'; + +import Title from 'ts/components/Title'; +import { getDate } from 'ts/helpers/formatter'; +import DayInfo from 'ts/components/DayInfo'; +import Description from 'ts/components/Description'; + +import type { DayEvents, DayEvent } from '../helpers/events'; +import dayInfoStore from '../store/DayInfo'; +import style from '../styles/day_info.module.scss'; + +function getRelease(event?: DayEvent) { + if (!event?.release) return ''; + const releases = Array + .from(event?.release || []) + .sort() + .join(', '); + return ` Release: ${releases}`; +} + +interface DayInfoHintProps { + events: DayEvents; +} + +const DayInfoHint = observer(({ + events, +}: DayInfoHintProps): React.ReactElement | null => { + if (!dayInfoStore.info) return null; + + const top = dayInfoStore.position?.[1] + 24; + const left = dayInfoStore.position?.[0] - 150; + const event = events.get(dayInfoStore.info.timestamp); + const release = getRelease(event); + + return ReactDOM.createPortal(( + <div + className={style.year_chart_day_info} + style={{ + top, + left, + }} + > + <div className={`${style.year_chart_day_info_body} scroll_y`}> + <Title + title={getDate(dayInfoStore.info.timestamp)} + className={style.year_chart_day_info_title} + /> + + {release ? ( + <Description + text={release} + className={style.year_chart_day_info_title} + /> + ) : null} + + <DayInfo + timestamp={dayInfoStore.info.timestamp} + events={event} + /> + </div> + </div> + ), document.body); +}); + +export default DayInfoHint; diff --git a/src/ts/components/YearChart/components/Header.tsx b/src/ts/components/YearChart/components/Header.tsx index b756ad4..18882e5 100644 --- a/src/ts/components/YearChart/components/Header.tsx +++ b/src/ts/components/YearChart/components/Header.tsx @@ -1,19 +1,22 @@ import React from 'react'; -import { getLangPrefix } from 'ts/helpers/formatter'; +import { DataGripMonth } from 'ts/helpers/DataGrip/components/month'; +import { getCustomDate } from 'ts/helpers/formatter'; -import IMonth from '../interfaces/Month'; import style from '../styles/index.module.scss'; interface IHeaderProps { - month: IMonth; + showYear: boolean; + month: DataGripMonth; } function Header({ + showYear, month, }: IHeaderProps): React.ReactElement | null { - const name = month.date.toLocaleString(getLangPrefix(), { month: 'long' }); - const showYear = month.first || month.last || !month.month; + const title = showYear + ? getCustomDate(month.milliseconds, { month: 'long', year: 'numeric' }) + : getCustomDate(month.milliseconds, { month: 'long' }); return ( <div className={style.year_chart_month_header}> @@ -21,7 +24,7 @@ function Header({ className={style.year_chart_month_header_title} style={{ fontWeight: showYear ? 'bold' : 100 }} > - {name} {showYear ? month.year : ''} + {title} </span> </div> ); diff --git a/src/ts/components/YearChart/components/Month.tsx b/src/ts/components/YearChart/components/Month.tsx index 62e9823..c258dc5 100644 --- a/src/ts/components/YearChart/components/Month.tsx +++ b/src/ts/components/YearChart/components/Month.tsx @@ -1,60 +1,64 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; -import IHashMap from 'ts/interfaces/HashMap'; -import { getShortMoney } from 'ts/helpers/formatter'; +import { DataGripMonth } from 'ts/helpers/DataGrip/components/month'; -import IMonth from '../interfaces/Month'; +import { Filters } from '../interfaces/Filters'; import Header from './Header'; import Body from './Body'; +import { DayEvents } from '../helpers/events'; import styleChart from '../styles/line.module.scss'; import style from '../styles/index.module.scss'; -interface IMonthProps { - max: IHashMap<number>; - month: IMonth; - showEvents: boolean; - hideMoney?: boolean; +interface MonthProps { + max: number; + showYear: boolean; + events: DayEvents; + filters: Filters; + month: DataGripMonth; } function Month({ max, + showYear, + events, + filters, month, - showEvents, - hideMoney, -}: IMonthProps): React.ReactElement | null { - let value = ''; - if (month.tasks) { - value = `☑ ${month.tasks || 0}`; - } - if (!hideMoney && month.money) { - value = `☑ ${month.tasks || 0} — ${getShortMoney(month.money || 0, 0)}`; - } - - const title = hideMoney - ? 'tasks' - : 'tasks and money'; - +}: MonthProps): React.ReactElement | null { + const { t } = useTranslation(); return ( <div className={style.year_chart_month}> - <Header month={month}/> - <Body + <Header month={month} - maxCommits={max.commits} - showEvents={showEvents} + showYear={showYear} /> - <div - title={title} - className={styleChart.year_chart_month_info} - > - {value} + <Body + max={max} + month={month} + events={events} + filters={filters} + /> + <div className={styleChart.year_chart_month_info}> + <img + title={t('page.team.week.tasks')} + className={style.year_chart_month_icon} + src="./assets/chart/tasks.svg" + /> + <span title={t('page.team.week.tasks')}> + {month.tasksNumber || 0} + </span> + <img + title={t('page.team.country.chart.item')} + className={style.year_chart_month_icon} + src="./assets/chart/person.svg" + /> + <span title={t('page.team.country.chart.item')}> + {month.usersNumber || 0} + </span> </div> </div> ); } -Month.defaultProps = { - hideMoney: false, -}; - export default Month; diff --git a/src/ts/components/YearChart/helpers/day.ts b/src/ts/components/YearChart/helpers/day.ts index 72036a1..e554012 100644 --- a/src/ts/components/YearChart/helpers/day.ts +++ b/src/ts/components/YearChart/helpers/day.ts @@ -1,32 +1,10 @@ -import IHashMap from 'ts/interfaces/HashMap'; - -import IMonth from '../interfaces/Month'; - -export function getPercentByMax(countCommit: number, max: number) { - const value = ((countCommit || 0) * 100) / max; +export function getPercentByMax(count: number, max: number) { + const value = ((count || 0) * 100) / max; return (value - value % 1) / 100; } -export function getIconUrl(month: IMonth, dayInMonth: number) { - const addPerson = month.firstDay?.[dayInMonth]; - const removePerson = month.lastDay?.[dayInMonth]; - if (addPerson && removePerson) return './assets/chart/commit.svg'; - if (removePerson) return './assets/chart/commit.svg'; - if (addPerson) return './assets/chart/commit.svg'; - return ''; -} - -export function getColor(isWeekend: boolean, opacity: number): string { - const colors = isWeekend ? [ - '#ED675F', // 1 - '#EB817C', // 0.8 - '#E98E8A', // 0.7 - '#E89B99', // 0.6 - '#E7A8A7', // 0.5 - '#E7B5B6', // 0.4 - '#E6C3C4', // 0.3 - '#E4CFD3', // 0.2 - ] : [ +export const COLORS = { + WORKS: [ '#4162B5', // 0 1 '#617DC1', // 1 0.8 '#718AC6', // 2 0.7 @@ -35,37 +13,45 @@ export function getColor(isWeekend: boolean, opacity: number): string { '#A2B3D8', // 5 0.4 '#B2C1DE', // 6 0.3 '#C2CEE4', // 7 0.2 - ]; + ], + WEEKEND: [ + '#ED675F', // 1 + '#EB817C', // 0.8 + '#E98E8A', // 0.7 + '#E89B99', // 0.6 + '#E7A8A7', // 0.5 + '#E7B5B6', // 0.4 + '#E6C3C4', // 0.3 + '#E4CFD3', // 0.2 + ], + SELECTED: [ + '#0E5C0C', // 1 + '#2B9829', // 0.8 + '#4FBF4C', // 0.7 + '#6DD26A', // 0.6 + '#88E185', // 0.5 + '#ACE4AA', // 0.4 + '#C2ECC1', // 0.3 + '#E1F7E1', // 0.2 + ], +}; + +export function getColor(colors: string[], opacity: number): string { if (opacity >= 0.8) return colors[1]; if (opacity >= 0.6) return colors[3]; if (opacity >= 0.4) return colors[5]; return colors[7]; } -export function getDayText(events: IHashMap<any>, timestamp: string): string { - const addEmployees = events?.firstCommit?.[timestamp]; - const removeEmployees = events?.lastCommit?.[timestamp]; - if (addEmployees && removeEmployees) return '+-'; - if (removeEmployees) return '-'; - if (addEmployees) return '+'; - return ''; +export function getDayWidth(wrapperWidth: number = 0) { + const minMonthWidth = 7 + 8 * 16; + const newMonthNumber = Math.floor(wrapperWidth / minMonthWidth); + const step = 0.3; + const borders = 7; + for (let px = 16; px <= 24; px += step) { + const monthWidth = borders + 8 * px; + const size = monthWidth * newMonthNumber; + if (size > wrapperWidth) return (px - step); + } + return 24; } - -function getRefAuthorByTime(list: any[], property: string) { - return list.reduce((refTimeAuthor: any, item: any) => { - if (item.isStaff) return refTimeAuthor; - if (property === 'lastCommit' && !item.isDismissed) return refTimeAuthor; - const key = item?.[property]?.timestamp; - if (!refTimeAuthor[key]) refTimeAuthor[key] = []; - refTimeAuthor[key].push(item.author); - return refTimeAuthor; - }, {}); -} - -export function getEvents(dataGripStore: any) { - const list = dataGripStore.dataGrip.author.statistic; - return { - firstCommit: getRefAuthorByTime(list, 'firstCommit'), - lastCommit: getRefAuthorByTime(list, 'lastCommit'), - }; -} \ No newline at end of file diff --git a/src/ts/components/YearChart/helpers/events.ts b/src/ts/components/YearChart/helpers/events.ts new file mode 100644 index 0000000..8425e7b --- /dev/null +++ b/src/ts/components/YearChart/helpers/events.ts @@ -0,0 +1,58 @@ +import { HashMap } from 'ts/interfaces/HashMap'; + +export interface DayEvent { + firstDay: Set<string> | undefined; + lastDay: Set<string> | undefined; + release: Set<string> | undefined; +} + +export type DayEvents = HashMap<DayEvent>; + +function getDayEvent(): DayEvent { + return { + firstDay: undefined, + lastDay: undefined, + release: undefined, + }; +} + +function updateEvent(events: DayEvents, timestamp: string, callback: Function) { + const day = events.get(timestamp) || getDayEvent(); + callback(day); + events.set(timestamp, day); +} + +function getCallback(property: string, name: string) { + return function updateEventProperty(event: DayEvent) { + if (event[property]) { + event[property].add(name); + } else { + event[property] = new Set([name]); + } + }; +} + +function addByAuthor(events: DayEvents, authors: any[]) { + authors.forEach((user: any) => { + if (user.isStaff) return; + + updateEvent(events, user.firstCommit.timestamp, getCallback('firstDay', user.author)); + + if (user.isDismissed) { + updateEvent(events, user.lastCommit.timestamp, getCallback('lastDay', user.author)); + } + }); +} + +function addByRelease(events: DayEvents, releases: any[]) { + releases.forEach((release: any) => { + updateEvent(events, release.lastCommit.timestamp, getCallback('release', release.title)); + }); +} + +export function getEvents(statisticByAuthors: any[], statisticByRelease: any[]) { + const events = new Map(); + addByAuthor(events, statisticByAuthors); + addByRelease(events, statisticByRelease); + return events; +} diff --git a/src/ts/components/YearChart/index.tsx b/src/ts/components/YearChart/index.tsx index 4852ef8..0038d8f 100644 --- a/src/ts/components/YearChart/index.tsx +++ b/src/ts/components/YearChart/index.tsx @@ -1,37 +1,28 @@ import React, { useEffect, useRef, useState } from 'react'; -import MinMaxCounter from 'ts/helpers/DataGrip/components/counter'; +import { DataGripMonth } from 'ts/helpers/DataGrip/components/month'; -import getCommitsByMonth from './helpers/getCommitsByMonth'; -import getAuthorByDate from './helpers/getAuthorByDate'; +import { Filters } from './interfaces/Filters'; +import DayInfoHint from './components/DayInfo'; import Month from './components/Month'; -import IMonth from './interfaces/Month'; +import type { DayEvents } from './helpers/events'; +import { getDayWidth } from './helpers/day'; +import dayInfoStore from './store/DayInfo'; import style from './styles/index.module.scss'; -function getDayWidth(wrapperWidth: number, monthNumber: number) { - const step = 0.3; - const borders = 7; - for (let px = 16; px <= 24; px += step) { - const monthWidth = borders + 8 * px; - const size = monthWidth * monthNumber; - if (size > wrapperWidth) return (px - step); - } - return 24; -} - interface IYearChartProps { - maxCommits: number; - showEvents?: boolean; - wordDays: any[]; - authors: any[]; + max?: number; + events: DayEvents; + months: DataGripMonth[]; + filters?: Filters; } function YearChart({ - maxCommits = 100, - showEvents = true, - wordDays = [], - authors = [], + max = 100, + events, + months = [], + filters = {}, }: IYearChartProps): React.ReactElement | null { const wrapper = useRef(null); const [dayWidth, setDayWidth] = useState<number>(16); @@ -39,45 +30,29 @@ function YearChart({ useEffect(() => { if (!wrapper.current) return; // @ts-ignore const size = wrapper.current?.getBoundingClientRect() || {}; - const minMonthWidth = 7 + 8 * 16; - const newMonthNumber = Math.floor(size.width / minMonthWidth); - const width = getDayWidth(size.width, newMonthNumber); - + const width = getDayWidth(size?.width); setDayWidth(width); + return () => dayInfoStore.close(); }, []); - if (!wordDays || !wordDays.length) return null; + if (!months?.length) return null; - const authorsByDate = getAuthorByDate(authors); - const months = getCommitsByMonth(wordDays, authorsByDate); - const hideMoney = authors?.length === 1; - - const max = { - tasks: new MinMaxCounter(), - money: new MinMaxCounter(), - }; - - months.forEach((month: IMonth) => { - max.tasks.update(month.tasks); - max.money.update(month.money); + const elements = months.map((month: DataGripMonth, index: number) => { + const prev = months[index - 1]; + return ( + <Month + key={month.id} + max={max} + events={events} + filters={filters} + showYear={prev?.year !== month?.year} + month={month} + /> + ); }); - const elements = months.map((month: IMonth) => ( - <Month - key={month.id} - max={{ - tasks: max.tasks.max, - money: max.money.max, - commits: maxCommits, - }} - month={month} - showEvents={showEvents} - hideMoney={hideMoney} - /> - )); - - - const customStyle = { '--day-size': `${dayWidth.toFixed(1)}px` } as React.CSSProperties; + const daySize = dayWidth.toFixed(1); + const customStyle = { '--day-size': `${daySize}px` } as React.CSSProperties; return ( <div @@ -86,12 +61,9 @@ function YearChart({ className={style.year_chart} > {elements} + <DayInfoHint events={events} /> </div> ); } -YearChart.defaultProps = { - showEvents: true, -}; - export default YearChart; diff --git a/src/ts/components/YearChart/interfaces/Filters.ts b/src/ts/components/YearChart/interfaces/Filters.ts new file mode 100644 index 0000000..c78947c --- /dev/null +++ b/src/ts/components/YearChart/interfaces/Filters.ts @@ -0,0 +1,6 @@ +export interface Filters { + release?: boolean; + firstLastDays?: boolean; + types?: string[]; + authors?: string[]; +} diff --git a/src/ts/components/YearChart/store/DayInfo.ts b/src/ts/components/YearChart/store/DayInfo.ts new file mode 100644 index 0000000..26a1c6c --- /dev/null +++ b/src/ts/components/YearChart/store/DayInfo.ts @@ -0,0 +1,40 @@ +import { observable, action, makeObservable } from 'mobx'; + +import { DataGripDay } from 'ts/helpers/DataGrip/components/month'; + +type IPosition = [number, number]; + +class DayInfoStore { + info: DataGripDay | undefined = undefined; + + position: IPosition = [0, 0]; + + constructor() { + makeObservable(this, { + info: observable, + open: action, + close: action, + }); + } + + toggle(dayInfo: DataGripDay, position: IPosition) { + if (this.info?.timestamp === dayInfo?.timestamp) { + this.close(); + } else { + this.open(dayInfo); + this.position = position; + } + } + + open(dayInfo: DataGripDay) { + this.info = dayInfo; + } + + close() { + this.info = undefined; + } +} + +const dayInfoStore = new DayInfoStore(); + +export default dayInfoStore; diff --git a/src/ts/components/YearChart/styles/day_info.module.scss b/src/ts/components/YearChart/styles/day_info.module.scss new file mode 100644 index 0000000..97e6fcf --- /dev/null +++ b/src/ts/components/YearChart/styles/day_info.module.scss @@ -0,0 +1,83 @@ +@import 'src/styles/variables'; + +.year_chart_day_info { + position: absolute; + top: 0; + left: 0; + + font-size: var(--font-s); + font-weight: 100; + + display: inline-block; + width: 350px; + margin: 0; + padding: var(--space-l); + + border: 1px solid var(--color-border); + border-radius: var(--border-radius-m); + box-shadow: var(--space-xxs) var(--space-xxs) var(--space-xxs) var(--color-border); + + background-color: var(--color-white); + color: var(--color-black); + + &_body { + padding-right: var(--space-m); + max-height: 300px; + overflow-x: hidden; + } + + &:after { + position: absolute; + left: 142px; + top: -8px; + + content: ' '; + + display: inline-block; + width: 0; + height: 0; + + border: var(--space-l) solid var(--color-white); + border-right: none; + border-bottom: none; + box-shadow: -2px -2px 2px var(--color-border); + transform: rotateZ(45deg); + } + + &_title { + margin: 0 auto var(--space-m); + } + // + //&_table_wrapper { + // max-height: 200px; + //} + // + //&_table { + // column-gap: var(--space-xxs) + //} + // + //&_row { + // display: flex; + // align-items: center; + // justify-content: space-between; + // padding: var(--space-xxs) 0; + // + // &:nth-child(odd) { + // background-color: var(--color-border); + // } + //} + // + //&_name { + // padding-left: var(--space-xs); + // white-space: nowrap; + // text-overflow: ellipsis; + // overflow: hidden; + //} + // + //&_value { + // display: inline-block; + // text-align: right; + // padding-right: var(--space-xs); + // width: 30%; + //} +} diff --git a/src/ts/components/YearChart/styles/index.module.scss b/src/ts/components/YearChart/styles/index.module.scss index 4d22add..071b627 100644 --- a/src/ts/components/YearChart/styles/index.module.scss +++ b/src/ts/components/YearChart/styles/index.module.scss @@ -14,6 +14,14 @@ margin: var(--day-size) calc(var(--day-size) / 2) 0; vertical-align: top; + &_icon { + display: inline-block; + width: 16px; + height: 16px; + margin: -2px 2px 0 4px; + vertical-align: middle; + } + &_header { display: block; height: 24px; @@ -47,14 +55,28 @@ width: var(--day-size); height: var(--day-size); margin: 0 1px 1px 0; + + font-size: var(--font-m); + font-weight: bold; vertical-align: top; + line-height: var(--day-size); + overflow: hidden; cursor: pointer; text-align: center; + color: var(--color-black); background-color: var(--color-border); background-blend-mode: screen; -webkit-print-color-adjust: exact; + &_icon { + display: inline-block; + width: 96%; + height: 96%; + margin-top: 1px; + vertical-align: top; + } + &_arrow { position: absolute; top: 20px; diff --git a/src/ts/components/YearChart2/components/Body.tsx b/src/ts/components/YearChart2/components/Body.tsx new file mode 100644 index 0000000..a780578 --- /dev/null +++ b/src/ts/components/YearChart2/components/Body.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import dataGripStore from 'ts/store/DataGrip'; + +import Day from './Day'; +import IMonth from '../interfaces/Month'; +import style from '../styles/index.module.scss'; +import { getEvents } from '../helpers/day'; + +interface IBodyProps { + month: IMonth; + maxCommits: number; + showEvents: boolean; +} + +function Body({ + month, + maxCommits, + showEvents, +}: IBodyProps): React.ReactElement | null { + const firstDay = month.date.getDay() - 1; + const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const lastDay = firstDay + daysInMonth[month.month]; + const allDays = (new Array(6 * 7)).fill(0); + let currentDay = 0; + + const events = showEvents ? getEvents(dataGripStore) : {}; + const days = allDays.map((v: any, index: number) => { + const dayInfo = month.commits[currentDay]; + + if (dayInfo?.dayInMonth === (index - firstDay + 1)) { + currentDay += 1; + return ( + <Day + key={index} + month={month} + maxCommits={maxCommits} + dayNumber={index} + dayInfo={dayInfo} + events={events} + /> + ); + } + + return ( + <div + key={index} + className={style.year_chart_month_body_day} + style={{ + opacity: (index < firstDay || index > lastDay) ? 0.3 : 1, + }} + /> + ); + }); + + return ( + <div className={style.year_chart_month_body}> + {days} + </div> + ); +} + +Body.defaultProps = { + rows: [], +}; + +export default Body; diff --git a/src/ts/components/YearChart2/components/Day.tsx b/src/ts/components/YearChart2/components/Day.tsx new file mode 100644 index 0000000..435bf83 --- /dev/null +++ b/src/ts/components/YearChart2/components/Day.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; + +import IHashMap from 'ts/interfaces/HashMap'; +import DayInfo from 'ts/components/DayInfo'; +import Title from 'ts/components/Title'; + +import { getDate } from 'ts/helpers/formatter'; + +import IMonth from '../interfaces/Month'; +import style from '../styles/index.module.scss'; + +import { + getPercentByMax, + getColor, + getIconUrl, getDayText, +} from '../helpers/day'; + +interface IDayProps { + maxCommits: number; + dayNumber: any; + month: IMonth; + dayInfo: any; + events: IHashMap<any>; +} + +function Day({ + month, + dayInfo, + maxCommits, + dayNumber, + events, +}: IDayProps): React.ReactElement | null { + const [showInfo, setShowInfo] = useState<boolean>(false); + const weekend = [5, 6, 12, 13, 19, 20, 26, 27, 33, 34, 40, 41]; + const opacity = getPercentByMax(dayInfo.commits, maxCommits); + const isWeekend = weekend.includes(dayNumber); + const backgroundColor = getColor(isWeekend, opacity); + const iconUrl = getIconUrl(month, dayInfo.dayInMonth); + const text = getDayText(events, dayInfo.timestamp); + + return ( // @ts-ignore + <div + className={style.year_chart_month_body_day} + title={`commits: ${dayInfo.commits}, tasks: ${dayInfo.tasksInDay || 0}`} + style={{ + backgroundColor, + backgroundImage: iconUrl ? `url(${iconUrl})` : '', + }} + onClick={() => { + setShowInfo(!showInfo); + }} + > + {showInfo ? ( + <> + {'◉'} + <div className={style.year_chart_month_body_day_arrow} /> + <div className={`${style.year_chart_month_body_day_info} scroll_y`}> + <Title title={getDate(dayInfo.timestamp)} /> + <DayInfo timestamp={dayInfo.timestamp} /> + </div> + </> + ) : text} + </div> + ); +} + +Day.defaultProps = { + rows: [], +}; + +export default Day; diff --git a/src/ts/components/YearChart2/components/Header.tsx b/src/ts/components/YearChart2/components/Header.tsx new file mode 100644 index 0000000..b756ad4 --- /dev/null +++ b/src/ts/components/YearChart2/components/Header.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { getLangPrefix } from 'ts/helpers/formatter'; + +import IMonth from '../interfaces/Month'; +import style from '../styles/index.module.scss'; + +interface IHeaderProps { + month: IMonth; +} + +function Header({ + month, +}: IHeaderProps): React.ReactElement | null { + const name = month.date.toLocaleString(getLangPrefix(), { month: 'long' }); + const showYear = month.first || month.last || !month.month; + + return ( + <div className={style.year_chart_month_header}> + <span + className={style.year_chart_month_header_title} + style={{ fontWeight: showYear ? 'bold' : 100 }} + > + {name} {showYear ? month.year : ''} + </span> + </div> + ); +} + +export default Header; diff --git a/src/ts/components/YearChart2/components/Month.tsx b/src/ts/components/YearChart2/components/Month.tsx new file mode 100644 index 0000000..62e9823 --- /dev/null +++ b/src/ts/components/YearChart2/components/Month.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import IHashMap from 'ts/interfaces/HashMap'; +import { getShortMoney } from 'ts/helpers/formatter'; + +import IMonth from '../interfaces/Month'; +import Header from './Header'; +import Body from './Body'; + +import styleChart from '../styles/line.module.scss'; +import style from '../styles/index.module.scss'; + +interface IMonthProps { + max: IHashMap<number>; + month: IMonth; + showEvents: boolean; + hideMoney?: boolean; +} + +function Month({ + max, + month, + showEvents, + hideMoney, +}: IMonthProps): React.ReactElement | null { + let value = ''; + if (month.tasks) { + value = `☑ ${month.tasks || 0}`; + } + if (!hideMoney && month.money) { + value = `☑ ${month.tasks || 0} — ${getShortMoney(month.money || 0, 0)}`; + } + + const title = hideMoney + ? 'tasks' + : 'tasks and money'; + + return ( + <div className={style.year_chart_month}> + <Header month={month}/> + <Body + month={month} + maxCommits={max.commits} + showEvents={showEvents} + /> + <div + title={title} + className={styleChart.year_chart_month_info} + > + {value} + </div> + </div> + ); +} + +Month.defaultProps = { + hideMoney: false, +}; + +export default Month; diff --git a/src/ts/components/YearChart2/helpers/day.ts b/src/ts/components/YearChart2/helpers/day.ts new file mode 100644 index 0000000..72036a1 --- /dev/null +++ b/src/ts/components/YearChart2/helpers/day.ts @@ -0,0 +1,71 @@ +import IHashMap from 'ts/interfaces/HashMap'; + +import IMonth from '../interfaces/Month'; + +export function getPercentByMax(countCommit: number, max: number) { + const value = ((countCommit || 0) * 100) / max; + return (value - value % 1) / 100; +} + +export function getIconUrl(month: IMonth, dayInMonth: number) { + const addPerson = month.firstDay?.[dayInMonth]; + const removePerson = month.lastDay?.[dayInMonth]; + if (addPerson && removePerson) return './assets/chart/commit.svg'; + if (removePerson) return './assets/chart/commit.svg'; + if (addPerson) return './assets/chart/commit.svg'; + return ''; +} + +export function getColor(isWeekend: boolean, opacity: number): string { + const colors = isWeekend ? [ + '#ED675F', // 1 + '#EB817C', // 0.8 + '#E98E8A', // 0.7 + '#E89B99', // 0.6 + '#E7A8A7', // 0.5 + '#E7B5B6', // 0.4 + '#E6C3C4', // 0.3 + '#E4CFD3', // 0.2 + ] : [ + '#4162B5', // 0 1 + '#617DC1', // 1 0.8 + '#718AC6', // 2 0.7 + '#8198CD', // 3 0.6 + '#91A6D2', // 4 0.5 + '#A2B3D8', // 5 0.4 + '#B2C1DE', // 6 0.3 + '#C2CEE4', // 7 0.2 + ]; + if (opacity >= 0.8) return colors[1]; + if (opacity >= 0.6) return colors[3]; + if (opacity >= 0.4) return colors[5]; + return colors[7]; +} + +export function getDayText(events: IHashMap<any>, timestamp: string): string { + const addEmployees = events?.firstCommit?.[timestamp]; + const removeEmployees = events?.lastCommit?.[timestamp]; + if (addEmployees && removeEmployees) return '+-'; + if (removeEmployees) return '-'; + if (addEmployees) return '+'; + return ''; +} + +function getRefAuthorByTime(list: any[], property: string) { + return list.reduce((refTimeAuthor: any, item: any) => { + if (item.isStaff) return refTimeAuthor; + if (property === 'lastCommit' && !item.isDismissed) return refTimeAuthor; + const key = item?.[property]?.timestamp; + if (!refTimeAuthor[key]) refTimeAuthor[key] = []; + refTimeAuthor[key].push(item.author); + return refTimeAuthor; + }, {}); +} + +export function getEvents(dataGripStore: any) { + const list = dataGripStore.dataGrip.author.statistic; + return { + firstCommit: getRefAuthorByTime(list, 'firstCommit'), + lastCommit: getRefAuthorByTime(list, 'lastCommit'), + }; +} \ No newline at end of file diff --git a/src/ts/components/YearChart/helpers/getAuthorByDate.ts b/src/ts/components/YearChart2/helpers/getAuthorByDate.ts similarity index 100% rename from src/ts/components/YearChart/helpers/getAuthorByDate.ts rename to src/ts/components/YearChart2/helpers/getAuthorByDate.ts diff --git a/src/ts/components/YearChart/helpers/getCommitsByMonth.ts b/src/ts/components/YearChart2/helpers/getCommitsByMonth.ts similarity index 100% rename from src/ts/components/YearChart/helpers/getCommitsByMonth.ts rename to src/ts/components/YearChart2/helpers/getCommitsByMonth.ts diff --git a/src/ts/components/YearChart2/index.tsx b/src/ts/components/YearChart2/index.tsx new file mode 100644 index 0000000..0ae4247 --- /dev/null +++ b/src/ts/components/YearChart2/index.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import MinMaxCounter from 'ts/helpers/DataGrip/components/counter'; + +import getCommitsByMonth from './helpers/getCommitsByMonth'; +import getAuthorByDate from './helpers/getAuthorByDate'; +import Month from './components/Month'; +import IMonth from './interfaces/Month'; + +import style from './styles/index.module.scss'; + +function getDayWidth(wrapperWidth: number, monthNumber: number) { + const step = 0.3; + const borders = 7; + for (let px = 16; px <= 24; px += step) { + const monthWidth = borders + 8 * px; + const size = monthWidth * monthNumber; + if (size > wrapperWidth) return (px - step); + } + return 24; +} + +interface IYearChartProps { + maxCommits: number; + showEvents?: boolean; + wordDays: any[]; + authors: any[]; +} + +function YearChart({ + maxCommits = 100, + showEvents = true, + wordDays = [], + authors = [], +}: IYearChartProps): React.ReactElement | null { + const wrapper = useRef(null); + const [dayWidth, setDayWidth] = useState<number>(16); + + useEffect(() => { + if (!wrapper.current) return; // @ts-ignore + const size = wrapper.current?.getBoundingClientRect() || {}; + const minMonthWidth = 7 + 8 * 16; + const newMonthNumber = Math.floor(size.width / minMonthWidth); + const width = getDayWidth(size.width, newMonthNumber); + console.log(width); + setDayWidth(width); + }, []); + + if (!wordDays || !wordDays.length) return null; + + const authorsByDate = getAuthorByDate(authors); + const months = getCommitsByMonth(wordDays, authorsByDate); + const hideMoney = authors?.length === 1; + + const max = { + tasks: new MinMaxCounter(), + money: new MinMaxCounter(), + }; + + months.forEach((month: IMonth) => { + max.tasks.update(month.tasks); + max.money.update(month.money); + }); + + const elements = months.map((month: IMonth) => ( + <Month + key={month.id} + max={{ + tasks: max.tasks.max, + money: max.money.max, + commits: maxCommits, + }} + month={month} + showEvents={showEvents} + hideMoney={hideMoney} + /> + )); + + + const customStyle = { '--day-size': `${dayWidth.toFixed(1)}px` } as React.CSSProperties; + + return ( + <div + ref={wrapper} + style={customStyle} + className={style.year_chart} + > + {elements} + </div> + ); +} + +YearChart.defaultProps = { + showEvents: true, +}; + +export default YearChart; diff --git a/src/ts/components/YearChart/interfaces/Month.ts b/src/ts/components/YearChart2/interfaces/Month.ts similarity index 100% rename from src/ts/components/YearChart/interfaces/Month.ts rename to src/ts/components/YearChart2/interfaces/Month.ts diff --git a/src/ts/components/YearChart/interfaces/WorkDay.ts b/src/ts/components/YearChart2/interfaces/WorkDay.ts similarity index 100% rename from src/ts/components/YearChart/interfaces/WorkDay.ts rename to src/ts/components/YearChart2/interfaces/WorkDay.ts diff --git a/src/ts/components/YearChart2/styles/index.module.scss b/src/ts/components/YearChart2/styles/index.module.scss new file mode 100644 index 0000000..4d22add --- /dev/null +++ b/src/ts/components/YearChart2/styles/index.module.scss @@ -0,0 +1,97 @@ +@import 'src/styles/variables'; + +.year_chart { + --day-size: 16px; + padding: var(--space-xs) 0; + margin: 0 calc(var(--day-size) / -2); +} + +.year_chart_month { + --month-size: calc(var(--day-size) * 7 + 7px); + + display: inline-block; + width: var(--month-size); + margin: var(--day-size) calc(var(--day-size) / 2) 0; + vertical-align: top; + + &_header { + display: block; + height: 24px; + + &_title { + font-weight: 100; + font-size: var(--font-xs); + font-family: Arial, Verdana, sans-serif; + + display: block; + padding: 0; + margin: 0 auto; + + text-align: center; + line-height: var(--font-m); + text-decoration: none; + vertical-align: bottom; + color: var(--color-black); + } + } + + &_body { + position: relative; + display: block; + width: var(--month-size); + max-width: var(--month-size); + + &_day { + position: relative; + display: inline-block; + width: var(--day-size); + height: var(--day-size); + margin: 0 1px 1px 0; + vertical-align: top; + cursor: pointer; + text-align: center; + + background-color: var(--color-border); + background-blend-mode: screen; + -webkit-print-color-adjust: exact; + + &_arrow { + position: absolute; + top: 20px; + left: 0; + z-index: 1; + + display: inline-block; + width: 0; + height: 0; + + transform: rotateZ(-45deg); + border: 16px solid white; + border-right: none; + border-bottom: none; + background-color: var(--color-white); + } + + &_info { + font-size: var(--font-s); + position: absolute; + left: -175px; + top: var(--space-xxl); + z-index: 1; + + display: block; + width: 350px; + max-height: 350px; + overflow-x: hidden; + padding: var(--space-s); + + cursor: default; + text-align: left; + border-radius: var(--border-radius-m); + box-shadow: 2px 2px 5px var(--color-border); + border: 1px solid var(--color-border); + background-color: var(--color-white); + } + } + } +} diff --git a/src/ts/components/YearChart2/styles/line.module.scss b/src/ts/components/YearChart2/styles/line.module.scss new file mode 100644 index 0000000..3fc2486 --- /dev/null +++ b/src/ts/components/YearChart2/styles/line.module.scss @@ -0,0 +1,24 @@ +@import 'src/styles/variables'; + +.year_chart_month { + &_info { + display: block; + + font-weight: 100; + font-size: var(--font-xs); + font-family: Arial, Verdana, sans-serif; + + padding: 0; + margin: var(--space-s) 0 0; + + line-height: var(--font-xs); + white-space: nowrap; + text-decoration: none; + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + + color: var(--color-11); + } +} diff --git a/src/ts/helpers/DataGrip/components/month.ts b/src/ts/helpers/DataGrip/components/month.ts new file mode 100644 index 0000000..9b2a404 --- /dev/null +++ b/src/ts/helpers/DataGrip/components/month.ts @@ -0,0 +1,119 @@ +import { HashMap } from 'ts/interfaces/HashMap'; +import ICommit from 'ts/interfaces/Commit'; + +export interface DataGripDay { + timestamp: string; + dayInMonth: number; + commitsNumber: number; + userCommitNumbers: HashMap<number>; + typeCommitNumbers: HashMap<number>; +} + +export interface DataGripMonth { + id: string; + month: number; + year: number; + date: Date; + milliseconds: number; + + days: DataGripDay[]; + tasksNumber: number; + usersNumber: number; +} + +export default class DataGripByMonth { + months: HashMap<any> = new Map(); + + statistic: DataGripMonth[] = []; + + maxCommitsInDay: number = 0; + + clear() { + this.months.clear(); + this.statistic = []; + this.maxCommitsInDay = 0; + } + + #getKey(commit: ICommit) { + return `${commit.year}-${commit.month}`; + } + + addCommit(commit: ICommit) { + const key = this.#getKey(commit); + const statistic = this.months.get(key); + if (statistic) { + this.#update(statistic, commit); + } else { + this.#add(commit); + } + } + + #update(statistic: any, commit: ICommit) { + const days = statistic.days.get(commit.dayInMonth); + if (days) { + this.#updateDay(days, commit); + } else { + this.#addDay(statistic.days, commit); + } + + statistic.tasksNumber.add(commit.task); + statistic.usersNumber.add(commit.author); + } + + #add(commit: ICommit) { + const key = this.#getKey(commit); + const days = new Map(); + this.#addDay(days, commit); + this.months.set(key, { + id: key, + month: commit.month, + year: commit.year, + milliseconds: commit.milliseconds, + date: new Date(commit.milliseconds), + days, + tasksNumber: new Set([commit.task]), + usersNumber: new Set([commit.author]), + }); + } + + #updateDay(statistic: any, commit: ICommit) { + statistic.commitsNumber += 1; + statistic.userCommitNumbers.set( + commit.author, + (statistic.userCommitNumbers.get(commit.author) || 0) + 1, + ); + statistic.typeCommitNumbers.set( + commit.type, + (statistic.typeCommitNumbers.get(commit.type) || 0) + 1, + ); + if (statistic.commitsNumber > this.maxCommitsInDay) { + this.maxCommitsInDay = statistic.commitsNumber; + } + } + + #addDay(hashMap: any, commit: ICommit) { + hashMap.set(commit.dayInMonth, { + dayInMonth: commit.dayInMonth, + timestamp: commit.timestamp, + commitsNumber: 1, + userCommitNumbers: new Map([[ commit.author, 1]]), + typeCommitNumbers: new Map([[ commit.type, 1]]), + }); + } + + updateTotalInfo(dataGripByAuthor: any) { + this.statistic = Array.from(this.months.values()) + .map((dot: any) => { + dot.days = Array.from(dot.days.values()); + dot.tasksNumber = Array.from(dot.tasksNumber).length; + console.log(Array.from(dot.usersNumber)); + dot.usersNumber = Array + .from(dot.usersNumber) // @ts-ignore + .filter((name) => !dataGripByAuthor.statisticByName[name]?.isStaff) + .length; + return dot; + }) + .sort((a: DataGripMonth, b: DataGripMonth) => a.milliseconds - b.milliseconds); + this.months.clear(); + } +} diff --git a/src/ts/helpers/DataGrip/index.ts b/src/ts/helpers/DataGrip/index.ts index 3d8f023..b2e6b8d 100644 --- a/src/ts/helpers/DataGrip/index.ts +++ b/src/ts/helpers/DataGrip/index.ts @@ -7,6 +7,7 @@ import DataGripByTeam from './components/team'; import DataGripByScope from './components/scope'; import DataGripByType from './components/type'; import DataGripByTimestamp from './components/timestamp'; +import DataGripByMonth from './components/month'; import DataGripByWeek from './components/week'; import MinMaxCounter from './components/counter'; import DataGripByGet from './components/get'; @@ -35,7 +36,9 @@ class DataGrip { type: any = new DataGripByType(); - timestamp: any = new DataGripByTimestamp(); + timestamp: any = new DataGripByTimestamp(); // deprecated + + month: any = new DataGripByMonth(); week: any = new DataGripByWeek(); @@ -66,6 +69,7 @@ class DataGrip { this.scope.clear(); this.type.clear(); this.timestamp.clear(); + this.month.clear(); this.week.clear(); this.recommendations.clear(); this.get.clear(); @@ -88,6 +92,7 @@ class DataGrip { this.scope.addCommit(commit); this.type.addCommit(commit); this.timestamp.addCommit(commit); + this.month.addCommit(commit); this.get.addCommit(commit); this.week.addCommit(commit); this.tasks.addCommit(commit); @@ -103,6 +108,7 @@ class DataGrip { this.scope.updateTotalInfo(); this.type.updateTotalInfo(); this.timestamp.updateTotalInfo(this.author); + this.month.updateTotalInfo(this.author); this.week.updateTotalInfo(this.author); this.recommendations.updateTotalInfo(this); this.tasks.updateTotalInfo(); diff --git a/src/ts/helpers/formatter.ts b/src/ts/helpers/formatter.ts index 07bd22d..cd56ca3 100644 --- a/src/ts/helpers/formatter.ts +++ b/src/ts/helpers/formatter.ts @@ -56,7 +56,7 @@ export function get2Number(time: number) { return time < 10 ? `0${time}` : time; } -export function getCustomDate(timestamp: string, options?: any) { +export function getCustomDate(timestamp: string | number, options?: any) { if (!timestamp) return ''; const date = new Date(timestamp); return date.toLocaleString(getLangPrefix(), options || { day: 'numeric', month: 'long', year: 'numeric' }); diff --git a/src/ts/helpers/mouseCoordinates.ts b/src/ts/helpers/mouseCoordinates.ts new file mode 100644 index 0000000..08e54f7 --- /dev/null +++ b/src/ts/helpers/mouseCoordinates.ts @@ -0,0 +1,101 @@ +function getRelativeWindow(event: any) { + if (!event.targetTouches + || !event.targetTouches.length) return { + x: event.clientX, + y: event.clientY, + }; + + const touch = event.targetTouches[0]; + + return { + x: touch.clientX, + y: touch.clientY, + }; +} + +function getPercentRelativeElement(event: any, element: any, isWithoutLimits: any) { + const coordinates = getRelativeElement(event, element, isWithoutLimits); + const elementSize = getElementSize(element); + + if (!isWithoutLimits) { + // this._addLimits(relativeMouseCoordinates, elementCoordinates); + } + + return { + x: Math.round((100 / elementSize.width) * coordinates.x), + y: Math.round((100 / elementSize.height) * coordinates.y), + }; +} + +function addLimits(relativeMouseCoordinates: any, elementCoordinates: any) { + if (relativeMouseCoordinates.y < 0) { + relativeMouseCoordinates.y = 0; + } + + if (relativeMouseCoordinates.x < 0) { + relativeMouseCoordinates.x = 0; + } + + const elementHeight = elementCoordinates.bottom - elementCoordinates.top; + if (relativeMouseCoordinates.y > elementHeight) { + relativeMouseCoordinates.y = elementHeight; + } + + const elementWidth = elementCoordinates.right - elementCoordinates.left; + if (relativeMouseCoordinates.x > elementWidth) { + relativeMouseCoordinates.x = elementWidth; + } + + return relativeMouseCoordinates; +} + +function getRelativeElement(event: any, element: any, isWithoutLimits = false, addInversion = false) { + const mouseCoordinates = getRelativeWindow(event); + const elementCoordinates = element.getBoundingClientRect(); + let y = elementCoordinates.bottom - mouseCoordinates.y; + if (addInversion) y = mouseCoordinates.y - elementCoordinates.top; + + const relativeMouseCoordinates = { + x: mouseCoordinates.x - elementCoordinates.left, + y: y, + }; + + if (!isWithoutLimits) { + addLimits(relativeMouseCoordinates, elementCoordinates); + } + + return relativeMouseCoordinates; +} + +function getElementSize(element: any) { + const elementCoordinates = element.getBoundingClientRect(); + + return { + height: elementCoordinates.bottom - elementCoordinates.top, + width: elementCoordinates.right - elementCoordinates.left, + }; +} + +function getRelativeDocumentForOther(event: any) { + return { + x: event.pageX, + y: event.pageY, + }; +} + +function getRelativeDocumentForIe(event: any) { + const html = document.documentElement; + const body = document.body; + + return { + x: event.clientX + (html && html.scrollLeft || body && body.scrollLeft || 0) - (html.clientLeft || 0), + y: event.clientY + (html && html.scrollTop || body && body.scrollTop || 0) - (html.clientTop || 0), + }; +} + +export default function getRelativeDocument(event: any) { + if (event.pageX === null && event.clientX !== null) { + return getRelativeDocumentForIe(event); + } + return getRelativeDocumentForOther(event); +} diff --git a/src/ts/pages/Common/components/Changes.tsx b/src/ts/pages/Common/components/Changes.tsx index a9a342d..8941669 100644 --- a/src/ts/pages/Common/components/Changes.tsx +++ b/src/ts/pages/Common/components/Changes.tsx @@ -59,10 +59,7 @@ function Changes({ statistic }: IChangesProps) { <br/> <Title title={`${getDate(selected?.timestamp)} изменили ${selected?.addedAndChanges || '_'} строк`}/> <PageWrapper template="box"> - <DayInfo - day={selected} - order={dataGripStore.dataGrip.author.list} - /> + <DayInfo timestamp={selected?.timestamp} /> </PageWrapper> </> ); diff --git a/src/ts/pages/Common/components/Commits.tsx b/src/ts/pages/Common/components/Commits.tsx index 83b2bc1..b419b0f 100644 --- a/src/ts/pages/Common/components/Commits.tsx +++ b/src/ts/pages/Common/components/Commits.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; -import dataGripStore from 'ts/store/DataGrip'; import { getDate, getDateByTimestamp } from 'ts/helpers/formatter'; import Recommendations from 'ts/components/Recommendations'; @@ -63,10 +62,7 @@ function Commits({ statistic }: ICommitsProps) { selected?.commits, )} /> <PageWrapper template="box"> - <DayInfo - day={selected} - order={dataGripStore.dataGrip.author.list} - /> + <DayInfo timestamp={selected?.timestamp} /> </PageWrapper> </> ); diff --git a/src/ts/pages/Person/components/Month.tsx b/src/ts/pages/Person/components/Month.tsx index e31eb8f..67d7ea0 100644 --- a/src/ts/pages/Person/components/Month.tsx +++ b/src/ts/pages/Person/components/Month.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; import dataGripStore from 'ts/store/DataGrip'; -import YearChart from 'ts/components/YearChart'; +import YearChart from 'ts/components/YearChart2'; import PageWrapper from 'ts/components/Page/wrapper'; import IPersonCommonProps from '../interfaces/CommonProps'; diff --git a/src/ts/pages/Team/components/Country/styles/index.module.scss b/src/ts/pages/Team/components/Country/styles/index.module.scss index d811d17..ebc1c9e 100644 --- a/src/ts/pages/Team/components/Country/styles/index.module.scss +++ b/src/ts/pages/Team/components/Country/styles/index.module.scss @@ -21,7 +21,9 @@ @media (max-width: 900px) { .team_country_filter_select { + display: block; min-width: 100%; + margin-bottom: var(--space-xxl) } } diff --git a/src/ts/pages/Team/components/Month/Filters.tsx b/src/ts/pages/Team/components/Month/Filters.tsx new file mode 100644 index 0000000..f8f2ac9 --- /dev/null +++ b/src/ts/pages/Team/components/Month/Filters.tsx @@ -0,0 +1,72 @@ +import React, { useMemo } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useTranslation } from 'react-i18next'; + +import UiKitCheckbox from 'ts/components/UiKit/components/Checkbox'; +import SelectWithButtons from 'ts/components/UiKit/components/SelectWithButtons'; +import { Filters } from 'ts/components/YearChart/interfaces/Filters'; +import dataGripStore from 'ts/store/DataGrip'; + +import style from '../Country/styles/index.module.scss'; + +function getFormattedUsers(rows: any[], titleForAll: string) { + const options = rows.map((title: string, id: number) => ({ id: id + 1, title })); + options.unshift({ id: 0, title: titleForAll }); + return options; +} + +interface MonthFiltersProps { + filters: Filters; + onChange: Function; +} + +const MonthFilters = observer(({ + filters, + onChange, +}: MonthFiltersProps): React.ReactElement => { + const { t } = useTranslation(); + + const authors = dataGripStore.dataGrip.author.list; + const types = dataGripStore.dataGrip.type.list; + const authorsOptions = useMemo(() => getFormattedUsers(authors, t('page.team.month.authors')), [authors]); + const typesOptions = useMemo(() => getFormattedUsers(types, t('page.team.month.types')), [types]); + const update = (property: string, value: any) => { + onChange({ + ...filters, + [property]: value, + }); + }; + + return ( + <div className={style.team_country_filter}> + <SelectWithButtons + title="page.team.tree.filters.author" + className={style.team_country_filter_select} + value={filters?.authors?.[0] || authorsOptions[0]} + options={authorsOptions} + onChange={(id: number) => update('authors', [authorsOptions[id]])} + /> + <SelectWithButtons + title="page.team.tree.filters.author" + className={style.team_country_filter_select} + value={filters?.types?.[0] || typesOptions[0]} + options={typesOptions} + onChange={(id: number) => update('types', [typesOptions[id]])} + /> + <UiKitCheckbox + title="Релизы" + className={style.team_country_filter_checkbox} + value={filters.release} + onChange={() => update('release', !filters.release)} + /> + <UiKitCheckbox + title="Первый и последний день" + className={style.team_country_filter_checkbox} + value={filters.firstLastDays} + onChange={() => update('firstLastDays', !filters.firstLastDays)} + /> + </div> + ); +}); + +export default MonthFilters; diff --git a/src/ts/pages/Team/components/Month.tsx b/src/ts/pages/Team/components/Month/index.tsx similarity index 50% rename from src/ts/pages/Team/components/Month.tsx rename to src/ts/pages/Team/components/Month/index.tsx index eb08ce8..088b900 100644 --- a/src/ts/pages/Team/components/Month.tsx +++ b/src/ts/pages/Team/components/Month/index.tsx @@ -1,22 +1,28 @@ -import React from 'react'; +import React, { useState } from 'react'; import { observer } from 'mobx-react-lite'; import dataGripStore from 'ts/store/DataGrip'; +import ICommonPageProps from 'ts/components/Page/interfaces/CommonPageProps'; import Recommendations from 'ts/components/Recommendations'; import YearChart from 'ts/components/YearChart'; +import { getEvents } from 'ts/components/YearChart/helpers/events'; +import { Filters } from 'ts/components/YearChart/interfaces/Filters'; +import PageWrapper from 'ts/components/Page/wrapper'; import Title from 'ts/components/Title'; -import ICommonPageProps from 'ts/components/Page/interfaces/CommonPageProps'; -import PageWrapper from 'ts/components/Page/wrapper'; +import MonthFilters from './Filters'; const Month = observer(({ mode, }: ICommonPageProps): React.ReactElement => { - const authors = dataGripStore.dataGrip.author.statistic; - const statistic = dataGripStore.dataGrip.timestamp.statistic; - const max = statistic.commitsByTimestampCounter.max; + const statistic = dataGripStore.dataGrip.month; + const statisticByAuthor = dataGripStore.dataGrip.author.statistic; + const statisticByRelease = dataGripStore.dataGrip.release.statistic; const recommendations = dataGripStore.dataGrip.recommendations.team?.byTimestamp; + const events = getEvents(statisticByAuthor, statisticByRelease); + const defaultFilters = { release: false, firstLastDays: true }; + const [filters, setFilters] = useState<Filters>(defaultFilters); return ( <> @@ -27,11 +33,18 @@ const Month = observer(({ /> )} <Title title="page.team.month.title"/> + <PageWrapper> + <MonthFilters + filters={filters} + onChange={setFilters} + /> + </PageWrapper> <PageWrapper template="table"> <YearChart - maxCommits={max} - authors={authors} - wordDays={statistic.allCommitsByTimestamp} + max={statistic.maxCommitsInDay} + events={events} + months={statistic.statistic} + filters={filters} /> </PageWrapper> </> diff --git a/src/ts/translations/de/pages.ts b/src/ts/translations/de/pages.ts index 3bcb603..82acf34 100644 --- a/src/ts/translations/de/pages.ts +++ b/src/ts/translations/de/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Project work calendar § page.team.month.first: (first work day) § page.team.month.last: (last work day) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: Feature statistics § page.team.scope.scope: Feature § page.team.scope.days: Working Days diff --git a/src/ts/translations/en/pages.ts b/src/ts/translations/en/pages.ts index 8d3e92f..f449cd6 100644 --- a/src/ts/translations/en/pages.ts +++ b/src/ts/translations/en/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Project work calendar § page.team.month.first: (first work day) § page.team.month.last: (last work day) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: Feature statistics § page.team.scope.scope: Feature § page.team.scope.days: Working Days diff --git a/src/ts/translations/es/pages.ts b/src/ts/translations/es/pages.ts index b66a605..6f9acce 100644 --- a/src/ts/translations/es/pages.ts +++ b/src/ts/translations/es/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Calendario del proyecto § page.team.month.first: (first work day) § page.team.month.last: (last work day) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: Estadísticas de módulos § page.team.scope.scope: Elaboración definitiva § page.team.scope.days: Siervo. día diff --git a/src/ts/translations/fr/pages.ts b/src/ts/translations/fr/pages.ts index 1b73d61..b2bfba9 100644 --- a/src/ts/translations/fr/pages.ts +++ b/src/ts/translations/fr/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Calendrier du projet § page.team.month.first: (first work day) § page.team.month.last: (last work day) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: Statistiques par module § page.team.scope.scope: Mise au point § page.team.scope.days: Esclave. jours diff --git a/src/ts/translations/ja/pages.ts b/src/ts/translations/ja/pages.ts index 8d3e92f..f449cd6 100644 --- a/src/ts/translations/ja/pages.ts +++ b/src/ts/translations/ja/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Project work calendar § page.team.month.first: (first work day) § page.team.month.last: (last work day) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: Feature statistics § page.team.scope.scope: Feature § page.team.scope.days: Working Days diff --git a/src/ts/translations/ko/pages.ts b/src/ts/translations/ko/pages.ts index 12600bb..5194459 100644 --- a/src/ts/translations/ko/pages.ts +++ b/src/ts/translations/ko/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: 프로젝트 작업 일정 § page.team.month.first: (첫 근무일) § page.team.month.last: (마지막 작업 일) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: 기능 통계 § page.team.scope.scope: 특징 § page.team.scope.days: 노예 일 diff --git a/src/ts/translations/pt/pages.ts b/src/ts/translations/pt/pages.ts index 8d3e92f..f449cd6 100644 --- a/src/ts/translations/pt/pages.ts +++ b/src/ts/translations/pt/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Project work calendar § page.team.month.first: (first work day) § page.team.month.last: (last work day) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: Feature statistics § page.team.scope.scope: Feature § page.team.scope.days: Working Days diff --git a/src/ts/translations/ru/pages.ts b/src/ts/translations/ru/pages.ts index e05b31d..5b0e965 100644 --- a/src/ts/translations/ru/pages.ts +++ b/src/ts/translations/ru/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Календарь работы по проекту § page.team.month.first: (первый рабочий день) § page.team.month.last: (последний рабочий день) +§ page.team.month.authors: Все сотрудники +§ page.team.month.types: Все типы § page.team.scope.title: Статистика по фичам § page.team.scope.scope: Фича § page.team.scope.days: Раб. дней diff --git a/src/ts/translations/src/output/pages.ts b/src/ts/translations/src/output/pages.ts index fd25c02..c862989 100644 --- a/src/ts/translations/src/output/pages.ts +++ b/src/ts/translations/src/output/pages.ts @@ -57,6 +57,8 @@ export default ` § page.team.month.title: Календарь работы по проекту § page.team.month.first: (первый рабочий день) § page.team.month.last: (последний рабочий день) +§ page.team.month.authors: Все сотрудники +§ page.team.month.types: Все сотрудники § page.team.scope.title: Статистика по фичам § page.team.scope.scope: Фича § page.team.scope.days: Раб. дней diff --git a/src/ts/translations/zh/pages.ts b/src/ts/translations/zh/pages.ts index bddaa53..a0cc32c 100644 --- a/src/ts/translations/zh/pages.ts +++ b/src/ts/translations/zh/pages.ts @@ -52,6 +52,8 @@ export default ` § page.team.month.title: 项目工作日历 § page.team.month.first: (first work day) § page.team.month.last: (last work day) +§ page.team.month.authors: All employees +§ page.team.month.types: All types § page.team.scope.title: 按模块划分的统计数字 § page.team.scope.scope: 修改 § page.team.scope.days: 工作天