diff --git a/components/RoadmapList/BulletConnector.module.css b/components/RoadmapList/BulletConnector.module.css new file mode 100644 index 00000000..8bdbc75d --- /dev/null +++ b/components/RoadmapList/BulletConnector.module.css @@ -0,0 +1,12 @@ +.bulletConnector { + /** + * This top value must be set to the size of the .bulletIcon_Wrapper class in order to not appear underneath + * transparent colors + */ + top: 26px; + width: 2px; + bottom: 0; + /** Spotlight Blue */ + background: var(--chakra-colors-spotLightBlue); + position: absolute; +} diff --git a/components/RoadmapList/BulletConnector.tsx b/components/RoadmapList/BulletConnector.tsx new file mode 100644 index 00000000..f66e353e --- /dev/null +++ b/components/RoadmapList/BulletConnector.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styles from './BulletConnector.module.css'; + +/** + * The vertical line connecting each bulletIcon across all rows + * @returns + */ +export default function BulletConnector ({ isLast, children }: {isLast: boolean, children?: React.ReactNode}) { + if (isLast) { + return null + } + return ( + {children} + ) +} diff --git a/components/RoadmapList/BulletIcon.module.css b/components/RoadmapList/BulletIcon.module.css new file mode 100644 index 00000000..3ff35adf --- /dev/null +++ b/components/RoadmapList/BulletIcon.module.css @@ -0,0 +1,51 @@ +.bulletIcon_Wrapper { + background: white; + height: 32px; + width: 32px; + border-radius: 100%; + flex-shrink: 0; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; +} + +.bulletIcon_Wrapper.notStarted { + border: 6px solid var(--chakra-colors-inactiveAccent); +} + +.bulletIcon_Wrapper.inProgress { + border: 6px solid var(--chakra-colors-progressGreenAccent); +} + +.bulletIcon_Wrapper.completed { + border: 6px solid var(--chakra-colors-progressGreenAccent); +} + +.bulletIcon { + height: 24px; + width: 24px; + border-radius: 100%; + flex-shrink: 0; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; +} + +.bulletIcon.notStarted { + border: 1px solid var(--chakra-colors-inactive); +} + +.bulletIcon.inProgress { + z-index: 1; /** Just to get rid of empty ruleset warning */ +} + +.bulletIcon.completed { + background: var(--chakra-colors-progressGreen); + border: 2px solid var(--chakra-colors-progressGreen); +} diff --git a/components/RoadmapList/BulletIcon.tsx b/components/RoadmapList/BulletIcon.tsx new file mode 100644 index 00000000..4503583f --- /dev/null +++ b/components/RoadmapList/BulletIcon.tsx @@ -0,0 +1,29 @@ +import { CircularProgress } from '@chakra-ui/react' +import React from 'react' +import styles from './BulletIcon.module.css' + +export default function BulletIcon ({ completion_rate }: {completion_rate: number}) { + const wrapperClassNames = [styles.bulletIcon_Wrapper] + const iconClassNames = [styles.bulletIcon] + let color = 'inactive' + if (completion_rate === 100) { + color = 'progressGreenAccent' + iconClassNames.push(styles.completed) + wrapperClassNames.push(styles.completed) + } else if (completion_rate > 0) { + iconClassNames.push(styles.inProgress) + wrapperClassNames.push(styles.inProgress) + color = 'progressGreen' + } else { + iconClassNames.push(styles.notStarted) + wrapperClassNames.push(styles.notStarted) + } + + return ( + + + + + + ) +} diff --git a/components/RoadmapList/RoadmapList.module.css b/components/RoadmapList/RoadmapList.module.css new file mode 100644 index 00000000..b9ed4aa3 --- /dev/null +++ b/components/RoadmapList/RoadmapList.module.css @@ -0,0 +1,3 @@ +.roadmapList { + padding-top: 1vh; +} diff --git a/components/RoadmapList/RoadmapListItemDefault.tsx b/components/RoadmapList/RoadmapListItemDefault.tsx new file mode 100644 index 00000000..2dd3a97d --- /dev/null +++ b/components/RoadmapList/RoadmapListItemDefault.tsx @@ -0,0 +1,101 @@ +import { Grid, GridItem, Center, Link, HStack, Text, Skeleton } from '@chakra-ui/react'; +import { LinkIcon } from '@chakra-ui/icons'; +import NextLink from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import SvgGitHubLogo from '../icons/svgr/SvgGitHubLogo'; +import BulletConnector from './BulletConnector'; +import BulletIcon from './BulletIcon'; +import { paramsFromUrl } from '../../lib/paramsFromUrl'; +import { dayjs } from '../../lib/client/dayjs'; +import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild'; +import { ViewMode } from '../../lib/enums'; +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; +import { ListIssueViewModel } from './types'; + +interface RoadmapListItemDefaultProps { + issue: ListIssueViewModel + index: number + issues: ListIssueViewModel[] +} + +function TitleText ({ hasChildren, issue }: Pick & {hasChildren: boolean}) { + return ( + + {hasChildren ? : null} {issue.title} + + ) +} + +function TitleTextMaybeLink ({ issue, hasChildren, index, childLink }: Pick & {hasChildren: boolean, index: number, childLink: string}) { + const titleText = + if (!hasChildren) { + return titleText + } + return ( + + + {titleText} + + + ) +} + +export default function RoadmapListItemDefault ({ issue, index, issues }: RoadmapListItemDefaultProps) { + const { owner, repo, issue_number } = paramsFromUrl(issue.html_url) + const childLink = getLinkForRoadmapChild({ issueData: issue, query: useRouter().query, viewMode: ViewMode.List }) + const globalLoadingState = useGlobalLoadingState(); + const hasChildren = childLink !== '#' + const issueDueDate = issue.due_date ? dayjs(issue.due_date).format('MMM D, YYYY') : 'unknown' + + return ( + + +
+ + {issueDueDate} + +
+
+ +
+ + + +
+
+ + + + + + + {owner}/{repo}#{issue_number} + + + + + + + + + +
+ +
+
+ + + {issue.description} + + +
+ ) +} diff --git a/components/RoadmapList/index.tsx b/components/RoadmapList/index.tsx new file mode 100644 index 00000000..be986888 --- /dev/null +++ b/components/RoadmapList/index.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { Radio, RadioGroup, Stack, Text } from '@chakra-ui/react'; + +import { IssueDataViewInput } from '../../lib/types'; +import RoadmapListItemDefault from './RoadmapListItemDefault'; +import styles from './RoadmapList.module.css'; +import { getTimeFromDateString } from '../../lib/helpers'; + +interface RoadmapListProps extends IssueDataViewInput { + maybe?: unknown +} + +/** + * Sorts milestones by due date, in ascending order (2022-01-01 before 2023-01-01) with invalid dates at the end. + * @param {ListIssueViewModel | IssueData} a + * @param {ListIssueViewModel | IssueData} b + * @returns + */ +function sortMilestones (a, b) { + // Get either a unix timestamp or Number.MAX_VALUE + const aTime = getTimeFromDateString(a.due_date, Number.MAX_VALUE) + const bTime = getTimeFromDateString(b.due_date, Number.MAX_VALUE) + + // if a is valid and b is not, MAX_VALUE - validTime will be positive. + // if b is valid and a is not, MAX_VALUE - validTime will be negative. + // if both are valid, or invalid, result is 0. + return aTime - bTime +} + +export default function RoadmapList({ issueDataState }: RoadmapListProps): JSX.Element { + const [groupBy, setGroupBy] = useState('directChildren') + + const [isDevModeGroupBy, _setIsDevModeGroupBy] = useState(false); + const [isDevModeDuplicateDates, _setIsDevModeDuplicateDates] = useState(false); + const [dupeDateToggleValue, setDupeDateToggleValue] = useState('show'); + const flattenedIssues = issueDataState.children.flatMap((issueData) => issueData.get({ noproxy: true })) + const sortedIssuesWithDueDates = flattenedIssues.sort(sortMilestones) + + let groupByToggle: JSX.Element | null = null + if (isDevModeGroupBy) { + groupByToggle = ( + + + Group By: + Current Children + Parent/Child + Month + None (flat) + + + ) + } + + let dupeDateToggle: JSX.Element | null = null + if (isDevModeDuplicateDates) { + dupeDateToggle = ( + + + Duplicate Dates: + Hide + Show + + + ) + } + + return ( + <> + {groupByToggle} + {dupeDateToggle} +
+ {sortedIssuesWithDueDates.map((issue, index) => ( + + ))} +
+ + ) +} diff --git a/components/RoadmapList/types.ts b/components/RoadmapList/types.ts new file mode 100644 index 00000000..e74a3e7a --- /dev/null +++ b/components/RoadmapList/types.ts @@ -0,0 +1,7 @@ +import { ImmutableObject } from '@hookstate/core'; +import { IssueData } from '../../lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ListIssueViewModel extends Pick, 'html_url' | 'title' | 'completion_rate' | 'due_date' | 'description' | 'children' | 'parent'> { + +} diff --git a/components/errors/ErrorNotificationDisplay.tsx b/components/errors/ErrorNotificationDisplay.tsx index 0336ce28..a7e16786 100644 --- a/components/errors/ErrorNotificationDisplay.tsx +++ b/components/errors/ErrorNotificationDisplay.tsx @@ -22,7 +22,7 @@ export function ErrorNotificationDisplay ({ errorsState, issueDataState }: Error return []; } const errors = errorsState.ornull.value; - if (viewMode != null && issueDataState.ornull != null) { + if (viewMode != null && issueDataState.ornull != null && errorFilters[viewMode] != null) { return errorFilters[viewMode](errors, issueDataState.ornull.value) } return errors; diff --git a/components/icons/svgr/SvgGitHubLogo.tsx b/components/icons/svgr/SvgGitHubLogo.tsx index 0b7a7f7e..4902891e 100644 --- a/components/icons/svgr/SvgGitHubLogo.tsx +++ b/components/icons/svgr/SvgGitHubLogo.tsx @@ -1,17 +1,15 @@ import * as React from 'react'; -const SvgGitHubLogo = (props) => (
- +const SvgGitHubLogo = (props) => ( -
); export default SvgGitHubLogo; diff --git a/components/icons/svgr/SvgListViewIcon.tsx b/components/icons/svgr/SvgListViewIcon.tsx new file mode 100644 index 00000000..bdb7e206 --- /dev/null +++ b/components/icons/svgr/SvgListViewIcon.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +const SvgDetailViewIcon = () => ( + /** + * From https://www.iconfinder.com/icons/326721/list_view_icon#svg + */ + + + + + + + + + +); + +export default SvgDetailViewIcon; diff --git a/components/roadmap-grid/RoadmapTabbedView.tsx b/components/roadmap-grid/RoadmapTabbedView.tsx index d3e3006a..6ab079dd 100644 --- a/components/roadmap-grid/RoadmapTabbedView.tsx +++ b/components/roadmap-grid/RoadmapTabbedView.tsx @@ -26,6 +26,8 @@ import Header from './header'; import styles from './Roadmap.module.css'; import { RoadmapDetailed } from './RoadmapDetailedView'; import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; +import SvgListViewIcon from '../icons/svgr/SvgListViewIcon'; +import RoadmapList from '../RoadmapList'; export function RoadmapTabbedView({ issueDataState, @@ -35,12 +37,13 @@ export function RoadmapTabbedView({ const router = useRouter(); // Defining what tabs to show and in what order - const tabs = ['Detailed View','Overview'] as const; + const tabs = ['Detailed View', 'Overview', 'List'] as const; // Mapping the views to the tabs const tabViewMap: Record = { 'Detailed View': ViewMode.Detail, 'Overview': ViewMode.Simple, + 'List': ViewMode.List, }; // Mapping the tabs to the views @@ -58,11 +61,13 @@ export function RoadmapTabbedView({ }, undefined, { shallow: true }); } - const renderTab = (title: string, index: number) => { + const renderTab = (title: typeof tabs[number], index: number) => { let TabIcon = SvgDetailViewIcon if (title == "Overview") { TabIcon = SvgOverviewIcon + } else if (title == "List") { + TabIcon = SvgListViewIcon } return ( @@ -80,11 +85,17 @@ export function RoadmapTabbedView({ ) }; - const renderTabPanel = (_title: string, index: number) => ( - - - - ); + const renderTabPanel = (title: typeof tabs[number], index: number) => { + let component = + if (title === 'List') { + component = + } + return ( + + {component} + + ) + }; return ( <> diff --git a/lib/backend/addToChildren.ts b/lib/backend/addToChildren.ts index ad65ce27..97e0ddd9 100644 --- a/lib/backend/addToChildren.ts +++ b/lib/backend/addToChildren.ts @@ -1,4 +1,4 @@ -import { getDueDate } from '../parser'; +import { getDescription, getDueDate } from '../parser'; import { GithubIssueDataWithGroupAndChildren, IssueData } from '../types'; import { ErrorManager } from './errorManager'; @@ -11,8 +11,10 @@ export function addToChildren( if (Array.isArray(data)) { const parentAsGhIssueData = parent as GithubIssueDataWithGroupAndChildren; let parentDueDate = ''; + let parentDescription = '' if (parentAsGhIssueData.body_html != null && parentAsGhIssueData.html_url != null) { parentDueDate = getDueDate(parentAsGhIssueData, errorManager).eta + parentDescription = getDescription(parentAsGhIssueData.body) } const parentParsed: IssueData['parent'] = { state: parent.state, @@ -23,6 +25,7 @@ export function addToChildren( node_id: parent.node_id, completion_rate: 0, // calculated on the client-side once all issues are loaded due_date: parentDueDate, + description: parentDescription }; return data.map((item: GithubIssueDataWithGroupAndChildren): IssueData => ({ labels: item.labels ?? [], @@ -35,6 +38,7 @@ export function addToChildren( node_id: item.node_id, parent: parentParsed, children: addToChildren(item.children, item, errorManager), + description: item.description.length === 0 ? getDescription(item.body) : item.description, })); } diff --git a/lib/backend/issue.ts b/lib/backend/issue.ts index 0547e999..db65d4ef 100644 --- a/lib/backend/issue.ts +++ b/lib/backend/issue.ts @@ -1,4 +1,5 @@ import { IssueStates } from '../enums'; +import { getDescription } from '../parser'; import { GithubIssueData } from '../types'; import { getOctokit } from './octokit'; @@ -21,6 +22,8 @@ export async function getIssue ({ owner, repo, issue_number }): Promise (typeof label !== 'string' ? label.name : label)) as string[], + description }; if (process.env.IS_LOCAL === 'true') { diff --git a/lib/client/errorFilters.ts b/lib/client/errorFilters.ts index a1841097..09278495 100644 --- a/lib/client/errorFilters.ts +++ b/lib/client/errorFilters.ts @@ -25,6 +25,7 @@ export const getIssueErrorFilter = (maxDepth: number) => export const errorFilters = { + [ViewMode.List]: getIssueErrorFilter(1), [ViewMode.Simple]: getIssueErrorFilter(1), [ViewMode.Detail]: getIssueErrorFilter(3), } diff --git a/lib/enums.ts b/lib/enums.ts index a6f5aca4..e2e21bf4 100644 --- a/lib/enums.ts +++ b/lib/enums.ts @@ -6,6 +6,7 @@ export enum RoadmapMode { export enum ViewMode { Detail = 'detail', Simple = 'simple', + List = 'list', } export enum DateGranularityState { diff --git a/lib/helpers.ts b/lib/helpers.ts index 300b1281..efa5e41b 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -36,3 +36,11 @@ export const getEtaDate = (data: string): string => { }; export const isValidChildren = (v) => /^children[:]?$/im.test(v); + +export const getTimeFromDateString = (dateString: string, defaultValue: number): number => { + try { + return new Date(dateString).getTime(); + } catch { + return defaultValue; + } +} diff --git a/lib/parser.ts b/lib/parser.ts index 0fa630f0..a57ba4a2 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -29,14 +29,21 @@ export const getDueDate = (issue: Pick line.trim().split(' ').slice(-1)[0] @@ -62,7 +69,7 @@ function getUrlStringForChildrenLine(line: string, issue: Pick): ParserGetChildrenResponse[] { // tasklists require the checkbox style format to recognize children - const lines = getSectionLines(issue.body, '```[tasklist]') + const lines = getCleanedSectionLines(issue.body, '```[tasklist]') .filter(ensureTaskListChild) .map(splitAndGetLastItem) .filter(Boolean); @@ -88,7 +95,7 @@ function getChildrenNew(issue: Pick): Pars // Could not find children using new tasklist format, // try to look for "children:" section } - const lines = getSectionLines(issue.body, 'children:').map(splitAndGetLastItem).filter(Boolean); + const lines = getCleanedSectionLines(issue.body, 'children:').map(splitAndGetLastItem).filter(Boolean); if (lines.length === 0) { throw new Error('Section missing or has no children') } @@ -152,3 +159,38 @@ export const getChildren = (issue: Pick { + if (issueBodyText.length === 0) return ''; + + const [firstLine, ...linesToParse] = getSectionLines(issueBodyText, 'description:') + .split(/\r\n|\r|\n/) // We do not want to replace multiple newlines, only one. + + // the first line may contain only "description:" or "description: This is the start of my description" + const firstLineContent = firstLine + .replace(/^.{0,}description:/, '') + .replace(/-->/g, '') // may be part of an HTML comment so it's hidden from the user. Remove the HTML comment end tag + .trim(); + + const descriptionLines: string[] = [] + if (firstLineContent !== '') { + descriptionLines.push(firstLineContent) + } + + for (const line of linesToParse.map((line) => line.trim())) { + if (line === '' || line.includes('children:') || line.includes('```[tasklist]') || line.includes('eta:')) { + break + } + descriptionLines.push(line.trim()) + } + + return descriptionLines.join('\n'); +} diff --git a/lib/types.d.ts b/lib/types.d.ts index b720d571..a12cb436 100644 --- a/lib/types.d.ts +++ b/lib/types.d.ts @@ -11,6 +11,7 @@ export interface GithubIssueData { title: string; state: IssueStates; root_issue?: boolean; + description: string; } export interface GithubIssueDataWithGroup extends GithubIssueData { @@ -47,6 +48,7 @@ export interface IssueData extends Omit; + description: string; } export interface RoadmapApiResponseSuccess { diff --git a/pages/_app.tsx b/pages/_app.tsx index d1d6ce24..a6bdcb28 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,5 +1,5 @@ import Head from 'next/head'; -import { ChakraProvider } from '@chakra-ui/react'; +import { ChakraProvider, extendTheme } from '@chakra-ui/react'; import { noSSR } from 'next/dynamic'; import React, { useEffect } from 'react'; import { onCLS, onFID, onLCP } from 'web-vitals'; @@ -9,6 +9,39 @@ import { setTelemetry, useTelemetry } from '../hooks/useTelemetry'; import './style.css'; import type { BrowserMetricsProvider } from '../lib/types'; +const theme = extendTheme({ + semanticTokens: { + colors: { + inactive: { + // darkGray: '#D7D7D7', + default: '#D7D7D7', + }, + inactiveAccent: { + // lightGray: '#EFEFEF', + default: '#EFEFEF', + }, + progressGreen: { + // progressGreen: '#7DE087', + default: '#7DE087', + }, + progressGreenAccent: { + // progressGreenAccent: 'rgba(125, 224, 135, 0.28)', + // default: 'rgba(125, 224, 135, 0.28)', + default: '#7de08747' + }, + spotLightBlue: { + default: '#1FA5FF', + }, + linkBlue: { + default: '#4987BD', + }, + text: { + default: '#313239' + } + }, + }, +}) + /** * We have to do funky imports here to satisfy nextjs since this package is cjs and ignite-metrics is esm * @@ -53,7 +86,7 @@ function App({ Component, pageProps }) { Starmap - + diff --git a/tests/unit/parser.test.ts b/tests/unit/parser.test.ts index 5d6b4a5b..1a48cb66 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -1,4 +1,4 @@ -import { getChildren } from '../../lib/parser'; +import { getChildren, getDescription } from '../../lib/parser'; /** * Test data obtained from calling getIssue() on github.com/protocol/engres/issues/5 on 2023-02-10 @ 5pm PST @@ -88,6 +88,25 @@ const expectedResult = [ }, ] +const exampleBodyWithDescription = `eta: 2023-10\r\ndescription:\r\nThis issue is intended to capture discussion around Testground's Roadmap\r\nBecause GitHub doesn't have any feature to comment on Markdown files, please use this issue to leave any feedback, proposals, etc.\r\n\r\nchildren:\r\n- https://github.com/testground/testground/issues/1533\r\n- https://github.com/testground/testground/issues/1512\r\n- https://github.com/testground/testground/issues/1514\r\n- https://github.com/testground/testground/issues/1524\r\n- https://github.com/testground/testground/issues/1529\r\n- https://github.com/testground/testground/issues/1523\r\n\r\n---\r\n\r\n# Current Roadmap\r\n\r\n🛣🗺 Roadmap document: https://github.com/testground/testground/blob/master/ROADMAP.md\r\n\r\nStarmap viewer for this roadmap: https://www.starmaps.app/roadmap/github.com/testground/testground/issues/1491#simple\r\n\r\n# Current Status of the Roadmap:\r\n\r\n- 2022-10-10: We don't have full maintainer alignment on Roadmap items and priorities. As we resolve these, we will update the roadmap.\r\n\r\n## Unresolved questions:\r\n\r\n- [ ] https://github.com/testground/testground/pull/1484#discussion_r992168145\r\n\r\n## Roadmap drafts:\r\n- 1st draft: https://github.com/testground/testground/pull/1484` + +const fullHtmlCommentDescription = ``; + +const htmlCommentHeaderDescription = ` +This is a sample description. +This is part of the description. + +This is not part of the description. +`; + +const exampleBodyWithDescriptionIpfsGui119 = `eta: 2022-11-16\r\n\r\ndescription: Deliver the soft-launch initial version of starmaps.app for use by EngRes teams\r\n\r\nchildren:\r\n` +const exampleBodyWithDescriptionIpfsGui112 = 'eta: 2023-01\r\n\r\ndescription: https://pl-strflt.notion.site/IPFS-Companion-Manifest-V3-update-44b223ff36ae413fb86e7dc5a131973c\r\n\r\nchildren:\r\n- [x] https://github.com/ipfs/ipfs-companion/pull/1054\r\n- [ ] https://github.com/ipfs/ipfs-companion/issues/666' + describe('parser', function() { describe('getChildren', function() { it('Can parse children from issue.body_html', function() { @@ -178,4 +197,30 @@ describe('parser', function() { ]) }) }) + + describe('getDescription', function() { + it('can safely handle an empty string', function() { + expect(getDescription('')).toBe(''); + }) + + it('Can parse description from issue.body without linebreaks between them', function() { + expect(getDescription(exampleBodyWithDescription)).toBe(`This issue is intended to capture discussion around Testground's Roadmap\nBecause GitHub doesn't have any feature to comment on Markdown files, please use this issue to leave any feedback, proposals, etc.`) + }) + + it('Can parse description wrapped by HTML comment', function() { + expect(getDescription(fullHtmlCommentDescription)).toBe(`This is a sample description.\nThis is part of the description.`) + }) + + it('Can parse description with HTML comment header', function() { + expect(getDescription(htmlCommentHeaderDescription)).toBe(`This is a sample description.\nThis is part of the description.`) + }) + + it('can get the description from ipfs/ipfs-gui#119', function() { + expect(getDescription(exampleBodyWithDescriptionIpfsGui119)).toBe('Deliver the soft-launch initial version of starmaps.app for use by EngRes teams') + }) + + it('can get the description from ipfs/ipfs-gui#112', function() { + expect(getDescription(exampleBodyWithDescriptionIpfsGui112)).toBe('https://pl-strflt.notion.site/IPFS-Companion-Manifest-V3-update-44b223ff36ae413fb86e7dc5a131973c') + }) + }) })