From 95e4f85fed540a452a0deeb2522abd882cb9afa7 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 15 Dec 2021 15:32:51 -0600 Subject: [PATCH] feat(www): refactor insights pages and add packages (#10282) * feat(www): update pages and add packages pages * chore(www): lint styles * refactor(www): update insights page * chore(www): add eslintrc for www * chore(www): remove log from Flex component * chore(www): update with stylelint fixes Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- packages/carbon-react/scss/_layer.scss | 9 + packages/carbon-react/tasks/build-styles.js | 4 + www/.eslintrc.js | 17 + www/package.json | 5 +- www/src/components/Box/Box.module.scss | 141 +++++ www/src/components/Box/index.js | 36 ++ www/src/components/Flex/Flex.module.scss | 111 ++++ www/src/components/Flex/index.js | 37 ++ www/src/components/Header/Header.module.scss | 27 + www/src/components/Header/index.js | 35 ++ www/src/components/Text/Text.module.scss | 14 + www/src/components/Text/index.js | 46 ++ www/src/components/WorkspaceList/index.js | 55 ++ www/src/crypto/murmur.js | 87 +++ www/src/github/index.js | 225 +++++++- www/src/pages/index.js | 13 +- www/src/pages/insights/[owner]/[repo].js | 525 +++++++------------ www/src/pages/insights/index.js | 52 ++ www/src/pages/packages/[package]/index.js | 79 +++ www/src/pages/packages/index.js | 77 +++ www/src/project/index.js | 249 +++++++++ www/src/scss/styles.scss | 14 +- yarn.lock | 3 + 23 files changed, 1520 insertions(+), 341 deletions(-) create mode 100644 packages/carbon-react/scss/_layer.scss create mode 100644 www/.eslintrc.js create mode 100644 www/src/components/Box/Box.module.scss create mode 100644 www/src/components/Box/index.js create mode 100644 www/src/components/Flex/Flex.module.scss create mode 100644 www/src/components/Flex/index.js create mode 100644 www/src/components/Header/Header.module.scss create mode 100644 www/src/components/Header/index.js create mode 100644 www/src/components/Text/Text.module.scss create mode 100644 www/src/components/Text/index.js create mode 100644 www/src/components/WorkspaceList/index.js create mode 100644 www/src/crypto/murmur.js create mode 100644 www/src/pages/insights/index.js create mode 100644 www/src/pages/packages/[package]/index.js create mode 100644 www/src/pages/packages/index.js create mode 100644 www/src/project/index.js diff --git a/packages/carbon-react/scss/_layer.scss b/packages/carbon-react/scss/_layer.scss new file mode 100644 index 000000000000..488e28165dc8 --- /dev/null +++ b/packages/carbon-react/scss/_layer.scss @@ -0,0 +1,9 @@ +// Code generated by @carbon/react. DO NOT EDIT. +// +// Copyright IBM Corp. 2018, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@forward '@carbon/styles/scss/layer'; diff --git a/packages/carbon-react/tasks/build-styles.js b/packages/carbon-react/tasks/build-styles.js index e525074f64cc..93d9adf42e40 100644 --- a/packages/carbon-react/tasks/build-styles.js +++ b/packages/carbon-react/tasks/build-styles.js @@ -35,6 +35,10 @@ async function build() { type: 'file', filepath: '_grid.scss', }, + { + type: 'file', + filepath: '_layer.scss', + }, { type: 'file', filepath: '_motion.scss', diff --git a/www/.eslintrc.js b/www/.eslintrc.js new file mode 100644 index 000000000000..81cae8a7c70b --- /dev/null +++ b/www/.eslintrc.js @@ -0,0 +1,17 @@ +/** + * Copyright IBM Corp. 2018, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +module.exports = { + extends: [require.resolve('../config/eslint-config-carbon/internal.js')], + rules: { + 'jsx-a11y/anchor-is-valid': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + }, +}; diff --git a/www/package.json b/www/package.json index 9b9383cfb2d7..6ecd9b4ed555 100644 --- a/www/package.json +++ b/www/package.json @@ -37,6 +37,9 @@ "sass": "^1.43.4" }, "devDependencies": { - "rimraf": "^3.0.2" + "fast-glob": "^3.2.7", + "lodash.merge": "^4.6.2", + "rimraf": "^3.0.2", + "semver": "^7.3.5" } } diff --git a/www/src/components/Box/Box.module.scss b/www/src/components/Box/Box.module.scss new file mode 100644 index 000000000000..5208cd90ab84 --- /dev/null +++ b/www/src/components/Box/Box.module.scss @@ -0,0 +1,141 @@ +// +// Copyright IBM Corp. 2016, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@use 'sass:string'; +@use '@carbon/react/scss/spacing'; + +// Padding +$index: 1; + +.p0 { + padding: 0; +} + +.px-0 { + padding-right: 0; + padding-left: 0; +} + +.py-0 { + padding-top: 0; + padding-bottom: 0; +} + +.pl-0 { + padding-left: 0; +} + +.pr-0 { + padding-right: 0; +} + +.pt-0 { + padding-top: 0; +} + +.pb-0 { + padding-bottom: 0; +} + +@each $key, $value in spacing.$spacing { + .p#{$index} { + padding: $value; + } + + .px-#{$index} { + padding-right: $value; + padding-left: $value; + } + + .py-#{$index} { + padding-top: $value; + padding-bottom: $value; + } + + .pl-#{$index} { + padding-left: $value; + } + + .pr-#{$index} { + padding-right: $value; + } + + .pt-#{$index} { + padding-top: $value; + } + + .pb-#{$index} { + padding-bottom: $value; + } + $index: $index + 1; +} + +// Margin +$index: 1; + +.m0 { + margin: 0; +} + +.mx-0 { + margin-right: 0; + margin-left: 0; +} + +.my-0 { + margin-top: 0; + margin-bottom: 0; +} + +.ml-0 { + margin-left: 0; +} + +.mr-0 { + margin-right: 0; +} + +.mt-0 { + margin-top: 0; +} + +.mb-0 { + margin-bottom: 0; +} + +@each $key, $value in spacing.$spacing { + .m#{$index} { + margin: $value; + } + + .mx-#{$index} { + margin-right: $value; + margin-left: $value; + } + + .my-#{$index} { + margin-top: $value; + margin-bottom: $value; + } + + .ml-#{$index} { + margin-left: $value; + } + + .mr-#{$index} { + margin-right: $value; + } + + .mt-#{$index} { + margin-top: $value; + } + + .mb-#{$index} { + margin-bottom: $value; + } + $index: $index + 1; +} diff --git a/www/src/components/Box/index.js b/www/src/components/Box/index.js new file mode 100644 index 000000000000..afcbab3bdb71 --- /dev/null +++ b/www/src/components/Box/index.js @@ -0,0 +1,36 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as classes from './Box.module.scss'; + +import cx from 'clsx'; +import React from 'react'; + +const keys = Object.keys(classes); + +function Box({ children, ...rest }) { + const child = React.Children.only(children); + const childProps = {}; + const tokens = keys.filter((key) => { + if (rest[key]) { + return true; + } + childProps[key] = rest[key]; + return false; + }); + const className = cx( + tokens.map((token) => { + return classes[token]; + }) + ); + return React.cloneElement(child, { + ...childProps, + className: cx(child.props.className, className), + }); +} + +export { Box }; diff --git a/www/src/components/Flex/Flex.module.scss b/www/src/components/Flex/Flex.module.scss new file mode 100644 index 000000000000..77f9a84845bd --- /dev/null +++ b/www/src/components/Flex/Flex.module.scss @@ -0,0 +1,111 @@ +// +// Copyright IBM Corp. 2016, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@use '@carbon/react/scss/spacing'; + +.flex { + display: flex; +} + +/// https://tailwindcss.com/docs/flex-wrap +.wrap { + flex-wrap: wrap; +} + +.wrap-reverse { + flex-wrap: wrap-reverse; +} + +.nowrap { + flex-wrap: nowrap; +} + +/// https://tailwindcss.com/docs/flex-direction +.row { + flex-direction: row; +} + +.row-reverse { + flex-direction: row-reverse; +} + +.col { + flex-direction: column; +} + +.col-reverse { + flex-direction: column-reverse; +} + +/// https://tailwindcss.com/docs/gap +.gap-0 { + gap: 0; +} + +.gap-x-0 { + column-gap: 0; +} + +.gap-y-0 { + row-gap: 0; +} + +$index: 1; + +@each $key, $value in spacing.$spacing { + .gap-#{$index} { + gap: $value; + } + + .gap-x-#{$index} { + column-gap: $value; + } + + .gap-y-#{$index} { + row-gap: $value; + } + + $index: $index + 1; +} + +/// https://tailwindcss.com/docs/justify-content +.justify-start { + justify-content: flex-start; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: flex-center; +} + +.justify-between { + justify-content: space-between; +} + +.justify-around { + justify-content: space-around; +} + +.justify-evenly { + justify-content: space-evenly; +} + +/// https://tailwindcss.com/docs/align-items +.align-start { + align-items: flex-start; +} + +.align-end { + align-items: flex-end; +} + +.align-center { + align-items: center; +} diff --git a/www/src/components/Flex/index.js b/www/src/components/Flex/index.js new file mode 100644 index 000000000000..902047f47502 --- /dev/null +++ b/www/src/components/Flex/index.js @@ -0,0 +1,37 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as classes from './Flex.module.scss'; + +import cx from 'clsx'; +import React from 'react'; +import { Box } from '../Box'; + +function Flex({ as: BaseComponent = 'div', children, ...rest }) { + const childProps = {}; + const utilityClasses = Object.keys(rest).filter((key) => { + if (classes[key]) { + return true; + } + childProps[key] = rest[key]; + return false; + }); + const className = cx( + classes.flex, + utilityClasses.map((key) => { + return classes[key]; + }) + ); + + return ( + + {children} + + ); +} + +export { Flex }; diff --git a/www/src/components/Header/Header.module.scss b/www/src/components/Header/Header.module.scss new file mode 100644 index 000000000000..db9261b3b893 --- /dev/null +++ b/www/src/components/Header/Header.module.scss @@ -0,0 +1,27 @@ +// +// Copyright IBM Corp. 2016, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +.header { + display: grid; + min-height: 4rem; + align-items: center; + padding: 0 1rem; + border-bottom: 2px solid #000000; + column-gap: 2rem; + grid-template-columns: max-content 1fr; +} + +.nav { + height: 100%; +} + +.links { + display: flex; + height: 100%; + align-items: center; + column-gap: 1rem; +} diff --git a/www/src/components/Header/index.js b/www/src/components/Header/index.js new file mode 100644 index 000000000000..83a594a640a6 --- /dev/null +++ b/www/src/components/Header/index.js @@ -0,0 +1,35 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as classes from './Header.module.scss'; +import Link from 'next/link'; + +function Header() { + return ( +
+ + Carbon + + +
+ ); +} + +export { Header }; diff --git a/www/src/components/Text/Text.module.scss b/www/src/components/Text/Text.module.scss new file mode 100644 index 000000000000..0969967dd4aa --- /dev/null +++ b/www/src/components/Text/Text.module.scss @@ -0,0 +1,14 @@ +// +// Copyright IBM Corp. 2016, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@use '@carbon/react/scss/type'; + +@each $token, $value in type.$tokens { + .#{$token} { + @include type.type-style($token); + } +} diff --git a/www/src/components/Text/index.js b/www/src/components/Text/index.js new file mode 100644 index 000000000000..3be004d7b707 --- /dev/null +++ b/www/src/components/Text/index.js @@ -0,0 +1,46 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as classes from './Text.module.scss'; + +import cx from 'clsx'; +import React from 'react'; +import { Box } from '../Box'; + +function Text({ children, ...rest }) { + const childProps = {}; + const names = Object.keys(rest) + .filter((key) => { + if (classes[key]) { + return true; + } + childProps[key] = rest[key]; + return false; + }) + .map((key) => { + return classes[key]; + }); + const className = cx(names); + + if (typeof children === 'string' || typeof children === 'number') { + return ( + + {children} + + ); + } + const child = React.Children.only(children); + return ( + + {React.cloneElement(child, { + className: cx(child.props.className, className), + })} + + ); +} + +export { Text }; diff --git a/www/src/components/WorkspaceList/index.js b/www/src/components/WorkspaceList/index.js new file mode 100644 index 000000000000..3a4ea50fc6c6 --- /dev/null +++ b/www/src/components/WorkspaceList/index.js @@ -0,0 +1,55 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + Grid, + Column, + Table, + TableHead, + TableHeader, + TableBody, + TableRow, + TableCell, +} from '@carbon/react'; +import Link from 'next/link'; + +function WorkspaceList({ workspaces }) { + return ( + + + + + + Package + Exports + Version + Directory + + + + {workspaces.map((workspace) => { + return ( + + + + {workspace.name} + + + - + {workspace.version} + {workspace.directory} + + ); + })} + +
+
+
+ ); +} + +export { WorkspaceList }; diff --git a/www/src/crypto/murmur.js b/www/src/crypto/murmur.js new file mode 100644 index 000000000000..f22b3c99fcab --- /dev/null +++ b/www/src/crypto/murmur.js @@ -0,0 +1,87 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +/** + * Murmur hash implementation + * @see https://github.com/garycourt/murmurhash-js + * @param {string} str + * @returns {string} + */ +function hash(str) { + const length = str.length; + const rem = length & 3; + const len = length ^ rem; + + let h = 0; + let i = 0; + let k; + + while (i !== len) { + const ch4 = str.charCodeAt(i + 3); + + k = + str.charCodeAt(i) ^ + (str.charCodeAt(i + 1) << 8) ^ + (str.charCodeAt(i + 2) << 16) ^ + ((ch4 & 0xff) << 24) ^ + ((ch4 & 0xff00) >> 8); + + i += 4; + + k = (k * 0x2d51 + (k & 0xffff) * 0xcc9e0000) >>> 0; + k = (k << 15) | (k >>> 17); + k = (k * 0x3593 + (k & 0xffff) * 0x1b870000) >>> 0; + h ^= k; + h = (h << 13) | (h >>> 19); + h = (h * 5 + 0xe6546b64) >>> 0; + } + + k = 0; + switch (rem) { + /* eslint-disable no-fallthrough */ + case 3: + k ^= str.charCodeAt(len + 2) << 16; + case 2: + k ^= str.charCodeAt(len + 1) << 8; + case 1: + k ^= str.charCodeAt(len); + + k = (k * 0x2d51 + (k & 0xffff) * 0xcc9e0000) >>> 0; + k = (k << 15) | (k >>> 17); + k = (k * 0x3593 + (k & 0xffff) * 0x1b870000) >>> 0; + h ^= k; + } + + h ^= length; + h ^= h >>> 16; + h = (h * 0xca6b + (h & 0xffff) * 0x85eb0000) >>> 0; + h ^= h >>> 13; + h = (h * 0xae35 + (h & 0xffff) * 0xc2b20000) >>> 0; + h ^= h >>> 16; + + h >>>= 0; + + if (!h) { + return '0'; + } + + let s = ''; + while (h) { + const d = h % 62; + s = BASE62[d] + s; + h = (h - d) / 62; + } + return s; +} + +module.exports = { + hash, +}; diff --git a/www/src/github/index.js b/www/src/github/index.js index a5ad4e1f7415..e342a964b8d1 100644 --- a/www/src/github/index.js +++ b/www/src/github/index.js @@ -5,9 +5,17 @@ * LICENSE file in the root directory of this source tree. */ +import { + isAfter, + isBefore, + parseISO, + isWithinInterval, + formatISO, +} from 'date-fns'; import fs from 'fs-extra'; import path from 'path'; -import { percent } from '../format'; +import { percent, percentChanged } from '../format'; +import { getSprintsByYear } from '../sprints'; const DATA_DIRECTORY = path.join(process.cwd(), 'data'); @@ -111,3 +119,218 @@ export function getLabelBreakdown(collection) { }; }); } + +export async function getIssueStatistics(owner, repo) { + const details = await getRepoIssueDetails({ + owner, + repo, + }); + + const filters = { + open: (issue) => { + return issue.state === 'open'; + }, + closed: (issue) => { + return issue.state === 'closed'; + }, + created: (interval) => (issue) => { + return isWithinInterval(parseISO(issue.created_at), interval); + }, + closed_at: (interval) => (issue) => { + if (issue.closed_at) { + return isWithinInterval(parseISO(issue.closed_at), interval); + } + return false; + }, + }; + + function apply(collection, ...filters) { + return filters.reduce((acc, filter) => { + const result = []; + + for (const item of acc) { + if (filter(item)) { + result.push(item); + } + } + + return result; + }, collection); + } + + function or(a, b) { + return (issue) => { + if (a(issue) || b(issue)) { + return true; + } + return false; + }; + } + + function mean(collection, getValue) { + let result = 0; + for (const item of collection) { + result += getValue(item); + } + return result / collection.length; + } + + function median(collection, getValue) { + const values = collection + .map((item) => { + return getValue(item); + }) + .sort(); + return values[Math.floor(collection.length / 2)]; + } + + function sum(collection, getValue) { + return collection.reduce((acc, item) => { + return acc + getValue(item); + }, 0); + } + + function stddev(collection, getValue) { + const count = collection.length; + const m = mean(collection, getValue); + const differences = collection.map((item) => { + const value = getValue(item); + const difference = value - m; + return Math.pow(difference, 2); + }); + const differences_sum = sum(differences, (i) => i); + const variance = differences_sum / count; + return Math.sqrt(variance); + } + + const open = apply(details.issues, filters.open); + const sprints = getSprintsByYear(2021) + .map((sprint) => { + const itemsWithActivity = apply( + details.issues, + or(filters.created(sprint), filters.closed_at(sprint)) + ); + const created = apply(details.issues, filters.created(sprint)); + const closed = apply(details.issues, filters.closed_at(sprint)); + const open = details.issues.filter((issue) => { + if (isAfter(parseISO(issue.created_at), sprint.end)) { + return false; + } + + if (issue.state === 'open') { + return true; + } + + if (isBefore(parseISO(issue.closed_at), sprint.end)) { + return false; + } + + return true; + }); + const states = { + open: { + total: open.length, + }, + created: { + total: created.length, + percent: percent(itemsWithActivity.length, created.length), + }, + closed: { + total: closed.length, + percent: percent(itemsWithActivity.length, closed.length), + }, + }; + return { + start: formatISO(sprint.start), + end: formatISO(sprint.end), + issues: { + states, + }, + }; + }) + .map((sprint, index, sprints) => { + if (index === 0) { + return sprint; + } + const prevSprint = sprints[index - 1]; + return { + ...sprint, + issues: { + ...sprint.issues, + states: { + ...sprint.issues.states, + open: { + ...sprint.issues.states.open, + change: percentChanged( + prevSprint.issues.states.open.total, + sprint.issues.states.open.total + ), + }, + created: { + ...sprint.issues.states.created, + change: percentChanged( + prevSprint.issues.states.created.total, + sprint.issues.states.created.total + ), + }, + closed: { + ...sprint.issues.states.closed, + change: percentChanged( + prevSprint.issues.states.closed.total, + sprint.issues.states.closed.total + ), + }, + }, + }, + }; + }); + + return { + issues: { + open: open.length, + labels: getLabelBreakdown(open).splice(0, 10), + }, + sprints: sprints.reverse(), + statistics: { + issues_closed_per_sprint: { + mean: mean(sprints, (sprint) => { + return sprint.issues.states.closed.total; + }), + median: median(sprints, (sprint) => { + return sprint.issues.states.closed.total; + }), + stddev: stddev(sprints, (sprint) => { + return sprint.issues.states.closed.total; + }), + }, + issues_created_per_sprint: { + mean: mean(sprints, (sprint) => { + return sprint.issues.states.created.total; + }), + median: median(sprints, (sprint) => { + return sprint.issues.states.created.total; + }), + stddev: stddev(sprints, (sprint) => { + return sprint.issues.states.created.total; + }), + }, + }, + }; +} + +export function getEnabledRepos() { + return [ + { + owner: 'carbon-design-system', + repo: 'carbon', + }, + { + owner: 'carbon-design-system', + repo: 'carbon-website', + }, + { + owner: 'carbon-design-system', + repo: 'carbon-charts', + }, + ]; +} diff --git a/www/src/pages/index.js b/www/src/pages/index.js index 1456f5e83f8d..dda24a0d7b2a 100644 --- a/www/src/pages/index.js +++ b/www/src/pages/index.js @@ -5,14 +5,23 @@ * LICENSE file in the root directory of this source tree. */ +import { Grid, Column } from '@carbon/react'; import React from 'react'; +import { Header } from '../components/Header'; +import { Text } from '../components/Text'; export default function IndexPage() { return ( <> -
Carbon
+
-

Support

+ + + +

Carbon Design System

+
+
+
); diff --git a/www/src/pages/insights/[owner]/[repo].js b/www/src/pages/insights/[owner]/[repo].js index c3e38643a505..4c98dc32909a 100644 --- a/www/src/pages/insights/[owner]/[repo].js +++ b/www/src/pages/insights/[owner]/[repo].js @@ -6,156 +6,203 @@ */ import { + Grid, + Column, unstable_Heading as Heading, - unstable_Section as Section, + Stack, + Table, + TableHead, + TableRow, + TableHeader, + TableBody, + TableCell, } from '@carbon/react'; -import { - isAfter, - isBefore, - parseISO, - isWithinInterval, - formatISO, - format, -} from 'date-fns'; +import { Caution, Warning } from '@carbon/react/icons'; +import { parseISO, format } from 'date-fns'; import { useRouter } from 'next/router'; import React from 'react'; -import { getRepoIssueDetails, getLabelBreakdown } from '../../../github'; -import { getSprintsByYear } from '../../../sprints'; -import { percent, percentChanged } from '../../../format'; +import { getIssueStatistics, getEnabledRepos } from '../../../github'; +import { Header } from '../../../components/Header'; +import { Text } from '../../../components/Text'; +import { Box } from '../../../components/Box'; +import { Flex } from '../../../components/Flex'; -export default function InsightsPage(props) { +export default function InsightPage(props) { const router = useRouter(); if (router.query.owner && router.query.repo) { const { issues, sprints, statistics } = props; return ( <> -
Carbon Design System
+
- Insights -
-
-

{router.query.repo}

-
-
- Overview -
-
Open issues
-
{issues.open}
-
Issues closed per sprint
-
{statistics.issues_closed_per_sprint.median}
-
Issues created per sprint
-
{statistics.issues_created_per_sprint.median}
-
-
-
- Labels -
    + + + +
    + + + + {router.query.repo} + + + +
    +
    + +
    + + + + Open issues + {issues.open} + + + + + Issues closed per sprint + + {statistics.issues_closed_per_sprint.median} + + + + + + Issues created per sprint + + {statistics.issues_created_per_sprint.median} + + + + +
    +
    +
    + + + + Period + Open issues + Issues closed + Issues created + Change + + + + {sprints.map((sprint) => { + const start = parseISO(sprint.start); + const end = parseISO(sprint.end); + + function formatChange(change) { + if (change) { + const symbol = change.type === 'increase' ? '+' : ''; + return `(${symbol}${change.value})`; + } + return ''; + } + + const stddevs = { + closed: + Math.abs( + statistics.issues_closed_per_sprint.mean - + sprint.issues.states.closed.total + ) / statistics.issues_closed_per_sprint.stddev, + created: + Math.abs( + statistics.issues_created_per_sprint.mean - + sprint.issues.states.created.total + ) / statistics.issues_created_per_sprint.stddev, + }; + const closedStyle = {}; + const createdStyle = {}; + let createdLabel = null; + let closedLabel = null; + + if (stddevs.created >= 2) { + createdStyle.background = 'red'; + createdStyle.color = 'black'; + createdLabel = ; + } else if (stddevs.created >= 1) { + createdStyle.background = 'yellow'; + createdStyle.color = 'black'; + createdLabel = ; + } + + if (stddevs.closed >= 2) { + closedStyle.background = 'red'; + closedStyle.color = 'black'; + closedLabel = ; + } else if (stddevs.closed >= 1) { + closedStyle.background = 'yellow'; + closedStyle.color = 'black'; + closedLabel = ; + } + + const delta = + sprint.issues.states.created.total - + sprint.issues.states.closed.total; + + return ( + + + {format(start, 'MMMM do')} -{' '} + {format(end, 'MMMM do')} + + + + {sprint.issues.states.open.total}{' '} + {formatChange(sprint.issues.states.open.change)} + + + + + + {sprint.issues.states.closed.total}{' '} + {formatChange( + sprint.issues.states.closed.change + )} + + {closedLabel} + + + + + + {sprint.issues.states.created.total}{' '} + {formatChange( + sprint.issues.states.created.change + )} + + {createdLabel} + + + + {`${delta > 0 ? '+' : ''}${delta}`} + + + ); + })} + +
    +
    +
    + + + Labels + + {issues.labels.map((label) => { return (
  1. - {label.percent} - {label.name} + {label.percent} {label.name}
  2. ); })} -
-
-
- Sprints - {sprints.map((sprint) => { - const start = parseISO(sprint.start); - const end = parseISO(sprint.end); - - function formatChange(change) { - if (change) { - const symbol = change.type === 'increase' ? '+' : ''; - return `(${symbol}${change.value})`; - } - return ''; - } - - const stddevs = { - closed: - Math.abs( - statistics.issues_closed_per_sprint.mean - - sprint.issues.states.closed.total - ) / statistics.issues_closed_per_sprint.stddev, - created: - Math.abs( - statistics.issues_created_per_sprint.mean - - sprint.issues.states.created.total - ) / statistics.issues_created_per_sprint.stddev, - }; - const closedStyle = {}; - const createdStyle = {}; - - if (stddevs.created >= 2) { - createdStyle.background = 'red'; - createdStyle.color = 'black'; - } else if (stddevs.created >= 1) { - createdStyle.background = 'yellow'; - createdStyle.color = 'black'; - } - - if (stddevs.closed >= 2) { - closedStyle.background = 'red'; - closedStyle.color = 'black'; - } else if (stddevs.closed >= 1) { - closedStyle.background = 'yellow'; - closedStyle.color = 'black'; - } - - const delta = - sprint.issues.states.created.total - - sprint.issues.states.closed.total; - - return ( -
-
- {format(start, 'MMMM do')} - {format(end, 'MMMM do')} -
-
-
Open issues
-
- {sprint.issues.states.open.total}{' '} - {formatChange(sprint.issues.states.open.change)} -
-
Issues closed
-
- {sprint.issues.states.closed.total}{' '} - {formatChange(sprint.issues.states.closed.change)} -
-
Issues created
-
- {sprint.issues.states.created.total}{' '} - {formatChange(sprint.issues.states.created.change)} -
-
Delta
-
{`${delta > 0 ? '+' : ''}${delta}`}
-
-
- ); - })} -
-
+ + +
); @@ -166,221 +213,27 @@ export default function InsightsPage(props) { export async function getStaticProps({ params }) { const { owner, repo } = params; - const details = await getRepoIssueDetails({ - owner, - repo, - }); - - const filters = { - open: (issue) => { - return issue.state === 'open'; - }, - closed: (issue) => { - return issue.state === 'closed'; - }, - created: (interval) => (issue) => { - return isWithinInterval(parseISO(issue.created_at), interval); - }, - closed_at: (interval) => (issue) => { - if (issue.closed_at) { - return isWithinInterval(parseISO(issue.closed_at), interval); - } - return false; - }, - }; - - function apply(collection, ...filters) { - return filters.reduce((acc, filter) => { - const result = []; - - for (const item of acc) { - if (filter(item)) { - result.push(item); - } - } - - return result; - }, collection); - } - - function or(a, b) { - return (issue) => { - if (a(issue) || b(issue)) { - return true; - } - return false; - }; - } - - function mean(collection, getValue) { - let result = 0; - for (const item of collection) { - result += getValue(item); - } - return result / collection.length; - } - - function median(collection, getValue) { - const values = collection - .map((item) => { - return getValue(item); - }) - .sort(); - return values[Math.floor(collection.length / 2)]; - } - - function sum(collection, getValue) { - return collection.reduce((acc, item) => { - return acc + getValue(item); - }, 0); - } - - function stddev(collection, getValue) { - const count = collection.length; - const m = mean(collection, getValue); - const differences = collection.map((item) => { - const value = getValue(item); - const difference = value - m; - return Math.pow(difference, 2); - }); - const differences_sum = sum(differences, (i) => i); - const variance = differences_sum / count; - return Math.sqrt(variance); - } - - const open = apply(details.issues, filters.open); - const sprints = getSprintsByYear(2021) - .map((sprint) => { - const itemsWithActivity = apply( - details.issues, - or(filters.created(sprint), filters.closed_at(sprint)) - ); - const created = apply(details.issues, filters.created(sprint)); - const closed = apply(details.issues, filters.closed_at(sprint)); - const open = details.issues.filter((issue) => { - if (isAfter(parseISO(issue.created_at), sprint.end)) { - return false; - } - - if (issue.state === 'open') { - return true; - } - - if (isBefore(parseISO(issue.closed_at), sprint.end)) { - return false; - } - - return true; - }); - const states = { - open: { - total: open.length, - }, - created: { - total: created.length, - percent: percent(itemsWithActivity.length, created.length), - }, - closed: { - total: closed.length, - percent: percent(itemsWithActivity.length, closed.length), - }, - }; - return { - start: formatISO(sprint.start), - end: formatISO(sprint.end), - issues: { - states, - }, - }; - }) - .map((sprint, index, sprints) => { - if (index === 0) { - return sprint; - } - const prevSprint = sprints[index - 1]; - return { - ...sprint, - issues: { - ...sprint.issues, - states: { - ...sprint.issues.states, - open: { - ...sprint.issues.states.open, - change: percentChanged( - prevSprint.issues.states.open.total, - sprint.issues.states.open.total - ), - }, - created: { - ...sprint.issues.states.created, - change: percentChanged( - prevSprint.issues.states.created.total, - sprint.issues.states.created.total - ), - }, - closed: { - ...sprint.issues.states.closed, - change: percentChanged( - prevSprint.issues.states.closed.total, - sprint.issues.states.closed.total - ), - }, - }, - }, - }; - }); + const statistics = await getIssueStatistics(owner, repo); return { props: { - issues: { - open: open.length, - labels: getLabelBreakdown(open).splice(0, 10), - }, - sprints: sprints.reverse(), - statistics: { - issues_closed_per_sprint: { - mean: mean(sprints, (sprint) => { - return sprint.issues.states.closed.total; - }), - median: median(sprints, (sprint) => { - return sprint.issues.states.closed.total; - }), - stddev: stddev(sprints, (sprint) => { - return sprint.issues.states.closed.total; - }), - }, - issues_created_per_sprint: { - mean: mean(sprints, (sprint) => { - return sprint.issues.states.created.total; - }), - median: median(sprints, (sprint) => { - return sprint.issues.states.created.total; - }), - stddev: stddev(sprints, (sprint) => { - return sprint.issues.states.created.total; - }), - }, - }, + ...statistics, }, }; } export async function getStaticPaths() { - return { - paths: [ - { - params: { - owner: 'carbon-design-system', - repo: 'carbon', - }, + const paths = getEnabledRepos().map(({ owner, repo }) => { + return { + params: { + owner, + repo, }, - { - params: { - owner: 'carbon-design-system', - repo: 'carbon-website', - }, - }, - ], + }; + }); + + return { fallback: false, + paths, }; } diff --git a/www/src/pages/insights/index.js b/www/src/pages/insights/index.js new file mode 100644 index 000000000000..e8cee0f57978 --- /dev/null +++ b/www/src/pages/insights/index.js @@ -0,0 +1,52 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Grid, Column } from '@carbon/react'; +import Link from 'next/link'; +import { getEnabledRepos } from '../../github'; +import { Header } from '../../components/Header'; +import { Text } from '../../components/Text'; + +export default function InsightsPage({ repos }) { + return ( + <> +
+
+ + + +

Insights

+
+
+ +
    + {repos.map((repo) => { + const { owner, repo: name } = repo; + const key = `${owner}:${name}`; + return ( +
  • + + {name} + +
  • + ); + })} +
+
+
+
+ + ); +} + +export async function getStaticProps() { + return { + props: { + repos: getEnabledRepos(), + }, + }; +} diff --git a/www/src/pages/packages/[package]/index.js b/www/src/pages/packages/[package]/index.js new file mode 100644 index 000000000000..997934bee5bc --- /dev/null +++ b/www/src/pages/packages/[package]/index.js @@ -0,0 +1,79 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Grid, Column, Stack } from '@carbon/react'; +import { Header } from '../../../components/Header'; +import { Text } from '../../../components/Text'; +import * as Project from '../../../project'; + +export default function PackagePage({ package: pkg }) { + return ( + <> +
+
+ +
+ + + +

+ {pkg.name} +

+
+
+
+
+
+
+ + ); +} + +export async function getStaticProps({ params }) { + const { package: id } = params; + const project = await Project.get(); + const workspace = project.findWorkspaceById(id); + + return { + props: { + package: { + id: workspace.id, + directory: workspace.directory, + name: workspace.name, + version: workspace.version, + }, + }, + }; +} + +export async function getStaticPaths() { + const project = await Project.get(); + const workspaces = project.workspaces + .filter((workspace) => { + if (workspace.name === 'www') { + return false; + } + return workspace.directory !== project.directory; + }) + .map((workspace) => { + return { + id: workspace.id, + }; + }); + const paths = workspaces.map((workspace) => { + return { + params: { + package: workspace.id, + }, + }; + }); + + return { + fallback: false, + paths, + }; +} diff --git a/www/src/pages/packages/index.js b/www/src/pages/packages/index.js new file mode 100644 index 000000000000..a0afc105342d --- /dev/null +++ b/www/src/pages/packages/index.js @@ -0,0 +1,77 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Grid, Column, Stack } from '@carbon/react'; +import path from 'path'; +import React from 'react'; +import * as Project from '../../project'; +import { Text } from '../../components/Text'; +import { Header } from '../../components/Header'; +import { WorkspaceList } from '../../components/WorkspaceList'; + +export default function PackagesPage({ workspaces }) { + return ( + <> +
+ +
+ + + +

Packages

+
+
+
+
+
+ +
+
+ + ); +} + +export async function getStaticProps() { + const project = await Project.get(); + const workspaces = project.workspaces + .filter((workspace) => { + if (workspace.name === 'www') { + return false; + } + return workspace.directory !== project.directory; + }) + .map((workspace) => { + return { + id: workspace.id, + name: workspace.name, + version: workspace.version, + directory: path.relative(project.directory, workspace.directory), + }; + }); + + workspaces.sort((a, b) => { + if (a.name.startsWith('@') && b.name.startsWith('@')) { + return a.name.localeCompare(b.name); + } + + if (a.name.startsWith('@')) { + return -1; + } + + if (b.name.startsWith('@')) { + return 1; + } + + return a.name.localeCompare(b.name); + }); + + return { + props: { + workspaces, + }, + }; +} diff --git a/www/src/project/index.js b/www/src/project/index.js new file mode 100644 index 000000000000..d20cf20d52d4 --- /dev/null +++ b/www/src/project/index.js @@ -0,0 +1,249 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import fs from 'fs-extra'; +import glob from 'fast-glob'; +import path from 'path'; +import semver from 'semver'; +import { hash } from '../crypto/murmur'; + +let defaultDirectory = process.cwd(); +let _project = null; + +export async function get(directory = defaultDirectory) { + if (!_project) { + _project = await Project.detect(directory); + } + return _project; +} + +class Project { + /** + * Detects the project root directory from a given current working directory + * @param {string} directory + * @returns Promise + */ + static async detect(directory) { + const rootDirectory = getProjectRoot(directory); + const workspace = await Workspace.load(rootDirectory); + return Project.create(rootDirectory, workspace); + } + + static create(directory, workspace) { + return new Project(directory, workspace); + } + + constructor(directory, workspace) { + this.directory = directory; + this.workspace = workspace; + } + + get workspaces() { + return [this.workspace, ...this.workspace.getWorkspaces()]; + } + + findWorkspace(name) { + const workspaces = this.workspaces; + return workspaces.find((workspace) => { + return workspace.name === name; + }); + } + + findWorkspaceById(id) { + const workspaces = this.workspaces; + + return workspaces.find((workspace) => { + return workspace.id === id; + }); + } +} + +/** + * Returns the root directory of a project, either as a workspace root with a + * collection of packages or a single project with a `package.json` + * @param {string} directory + * @returns {string} + */ +function getProjectRoot(directory) { + const packageJsonPaths = ancestors(directory).filter((directory) => { + return fs.existsSync(path.join(directory, 'package.json')); + }); + + const rootDirectory = + packageJsonPaths.length > 0 + ? packageJsonPaths[packageJsonPaths.length - 1] + : null; + + if (!rootDirectory) { + throw new Error( + `Unable to find a \`package.json\` file from directory: ${directory}` + ); + } + + return rootDirectory; +} + +const { root: ROOT_DIR } = path.parse(__dirname); + +/** + * Returns an array of the the directory and its ancestors + * @param {string} directory + * @returns {Array} + */ +function ancestors(directory) { + const result = [directory]; + let current = directory; + + while (current !== '') { + result.push(current); + + if (current !== ROOT_DIR) { + current = path.dirname(current); + } else { + current = ''; + } + } + + return result; +} + +class Workspace { + /** + * @param {string} directory + * @returns {object} + */ + static async load(directory) { + const tree = await loadWorkspace(directory); + const visited = new Map(); + const workspace = visit(tree); + + function visit(node) { + let workspace = Workspace.create(node); + + if (visited.has(workspace.id)) { + return visited.get(workspace.id); + } + + visited.set(workspace.id, workspace); + + node.workspaces.forEach((node) => { + const child = visit(node); + workspace.addChildWorkspace(child); + }); + + return workspace; + } + + for (const node of visited.values()) { + for (const dependency of node.dependencies.values()) { + if (!visited.has(dependency.name)) { + continue; + } + const workspace = visited.get(dependency.name); + if (semver.satisfies(workspace.version, dependency.version)) { + dependency.workspace = workspace; + } + } + } + + return workspace; + } + + static create(workspace) { + return new Workspace(workspace); + } + + constructor({ directory, name, version, dependencies }) { + this.directory = directory; + this.name = name; + this.version = version; + this.dependencies = dependencies; + this.workspaces = new Set(); + this.id = hash(this.directory); + } + + addChildWorkspace(workspace) { + this.workspaces.add(workspace); + } + + getWorkspaces() { + return Array.from(this.workspaces).flatMap((workspace) => { + return [workspace, ...workspace.getWorkspaces()]; + }); + } +} + +async function loadWorkspace(directory) { + const packageJsonPath = path.join(directory, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Unable to find package.json at ${packageJsonPath}`); + } + + const packageJson = await fs.readJson(packageJsonPath); + const types = ['dependencies', 'devDependencies', 'peerDependencies']; + const dependencies = []; + + for (const type of types) { + if (!packageJson[type]) { + continue; + } + + for (const [name, version] of Object.entries(packageJson[type])) { + dependencies.push({ + type, + name, + version, + }); + } + } + + if (packageJson.workspaces) { + if ( + !Array.isArray(packageJson.workspaces) && + !Array.isArray(packageJson.workspaces.packages) + ) { + throw new Error( + `Invalid workspace configuration found at ${packageJsonPath}` + ); + } + + const patterns = Array.isArray(packageJson.workspaces) + ? packageJson.workspaces + : packageJson.workspaces.packages; + const children = await glob( + patterns.map((pattern) => path.join(pattern, 'package.json')), + { + cwd: directory, + } + ).then((matches) => { + return Promise.all( + matches.map((match) => { + return loadWorkspace(path.dirname(path.join(directory, match))); + }) + ); + }); + + return { + directory, + dependencies, + name: packageJson.name, + version: packageJson.version, + workspaces: children, + }; + } + + return { + directory, + dependencies, + name: packageJson.name, + version: packageJson.version, + workspaces: [], + }; +} + +export { Project }; diff --git a/www/src/scss/styles.scss b/www/src/scss/styles.scss index f894d628057b..5a25b350e0b3 100644 --- a/www/src/scss/styles.scss +++ b/www/src/scss/styles.scss @@ -6,8 +6,20 @@ // @use '@carbon/react/scss/reset'; -@use '@carbon/react/scss/components/stack'; @use '@carbon/react/scss/spacing'; +@use '@carbon/react/scss/themes'; +@use '@carbon/react/scss/theme' with ( + $theme: themes.$g10 +); +@use '@carbon/react/scss/layer'; +@use '@carbon/react/scss/grid'; +@use '@carbon/react/scss/components/data-table'; +@use '@carbon/react/scss/components/stack'; + +body { + background: theme.$background; + color: theme.$text-primary; +} .statistics { display: grid; diff --git a/yarn.lock b/yarn.lock index a813c48761ef..b63e6c5eb07e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36138,13 +36138,16 @@ resolve@^2.0.0-next.3: "@octokit/rest": ^18.12.0 clsx: ^1.1.1 date-fns: ^2.25.0 + fast-glob: ^3.2.7 fs-extra: ^10.0.0 + lodash.merge: ^4.6.2 next: ^12.0.3 prop-types: ^15.7.2 react: ^17.0.2 react-dom: ^17.0.2 rimraf: ^3.0.2 sass: ^1.43.4 + semver: ^7.3.5 languageName: unknown linkType: soft