diff --git a/components/IssueCard.tsx b/components/IssueCard.tsx index e6031cd..857898e 100644 --- a/components/IssueCard.tsx +++ b/components/IssueCard.tsx @@ -3,10 +3,12 @@ import { marked } from "marked"; import React from "react"; import theme from "../utils/theme.css"; import { container } from "./IssueCard.css"; +import RelationshipGraph from "./RelationshipGraph"; +import { Context } from "../utils/AppContext"; -type Props = { issue: IssueDetail }; +type Props = { context: Context; issue: IssueDetail }; -const IssueCard = ({ issue }: Props) => ( +const IssueCard = ({ context, issue }: Props) => (
{issue.identifier} @@ -31,6 +33,7 @@ const IssueCard = ({ issue }: Props) => ( ) : null} + {issue.comments.length > 0 ? ( <>
diff --git a/components/IssueComparison.tsx b/components/IssueComparison.tsx index 8c93c29..f922d74 100644 --- a/components/IssueComparison.tsx +++ b/components/IssueComparison.tsx @@ -4,6 +4,7 @@ import { IssueDetail } from "../utils/linear"; import useIssues from "../utils/useIssues"; import IssueCard from "./IssueCard"; import { buttons, footer, header, main, submit } from "./IssueComparison.css"; +import RelationshipGraph from "./RelationshipGraph"; type Props = { context: Context; @@ -39,7 +40,9 @@ const IssueComparison = ({
{issueList.map((issue) => ( - +
+ +
))}
diff --git a/components/Layer.tsx b/components/Layer.tsx new file mode 100644 index 0000000..64d0b45 --- /dev/null +++ b/components/Layer.tsx @@ -0,0 +1,39 @@ +import { ReactNode, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +type Props = { + children: ReactNode; +}; + +let root: HTMLDivElement; + +if (typeof document !== "undefined") { + root = document.createElement("div"); + document.body.appendChild(root); +} + +const Layer = ({ children }: Props) => { + const [element, setElement] = useState(null); + + useEffect(() => { + if (!root) throw Error(" Unable to find root element."); + + const e = document.createElement("div"); + + e.style.setProperty("inset", "0"); + e.style.setProperty("position", "fixed"); + e.style.setProperty("z-index", "1"); + + root.appendChild(e); + + setElement(e); + + return () => { + root.removeChild(e); + }; + }, []); + + return element ? createPortal(children, element) : null; +}; + +export default Layer; diff --git a/components/NetworkGraph.tsx b/components/NetworkGraph.tsx new file mode 100644 index 0000000..60ea83d --- /dev/null +++ b/components/NetworkGraph.tsx @@ -0,0 +1,101 @@ +import { Graph as G, toD3 } from "graph-fns"; +import { + forceSimulation, + forceLink, + forceManyBody, + forceCenter, + SimulationNodeDatum, + SimulationLinkDatum, +} from "d3-force"; +import { Graph } from "@visx/network"; + +type Props = { + width: number; + height: number; + graph: G; + forceStrength?: number; + undirected?: boolean; + label?: (id: string) => string; + color?: (id: string) => string; +}; + +const NetworkGraph = ({ + width, + height, + graph, + forceStrength = 1500, + undirected = false, + label = (id) => id, + color = () => "#000", +}: Props) => { + const d3Graph = toD3(graph); + + forceSimulation(d3Graph.nodes) + .force( + "link", + forceLink< + SimulationNodeDatum & { id: string }, + SimulationLinkDatum + >(d3Graph.links).id((d) => d.id) + ) + .force("charge", forceManyBody().strength(-forceStrength)) + .force("center", forceCenter(width / 2, height / 2)) + .stop() + .tick(50); + + return ( + + + + + + + + { + const dx = link.target.x - link.source.x; + const dy = link.target.y - link.source.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const gx = Math.cos(Math.atan2(dy, dx)) * (distance < 64 ? 18 : 24); + const gy = Math.cos(Math.atan2(dx, dy)) * (distance < 64 ? 18 : 24); + const sx = link.source.x + gx; + const sy = link.source.y + gy; + const tx = link.target.x - gx; + const ty = link.target.y - gy; + return ( + + ); + }} + nodeComponent={({ node }) => ( + + {label(node.id)} + + )} + /> + + ); +}; + +export default NetworkGraph; diff --git a/components/RelationshipGraph.css.ts b/components/RelationshipGraph.css.ts new file mode 100644 index 0000000..0dd20f9 --- /dev/null +++ b/components/RelationshipGraph.css.ts @@ -0,0 +1,8 @@ +import { style } from "@vanilla-extract/css"; +import theme from "../utils/theme.css"; + +export const card = style({ + border: `1px solid ${theme.color.background}`, + borderRadius: 20, + overflow: "hidden", +}); diff --git a/components/RelationshipGraph.tsx b/components/RelationshipGraph.tsx new file mode 100644 index 0000000..2edf1ca --- /dev/null +++ b/components/RelationshipGraph.tsx @@ -0,0 +1,163 @@ +import { ParentSize } from "@visx/responsive"; +import { addEdge, create, Graph, isCyclic } from "graph-fns"; +import { useEffect, useState } from "react"; +import NetworkGraph from "../components/NetworkGraph"; +import { Data } from "../utils/AppContext"; +import { RelationSummary } from "../utils/linear"; +import Layer from "./Layer"; +import { card } from "./RelationshipGraph.css"; + +const getAncestors = ( + relations: Array, + identifier: string +): Array => { + const ancestors = relations + .filter((relation) => relation.relatedIssueIdentifier === identifier) + .map((relation) => relation.issueIdentifier); + return ancestors.length > 0 + ? ancestors.concat( + ancestors.flatMap((ancestor) => getAncestors(relations, ancestor)) + ) + : []; +}; + +const getDescendants = ( + relations: Array, + identifier: string +): Array => { + const descendants = relations + .filter((relation) => relation.issueIdentifier === identifier) + .map((relation) => relation.relatedIssueIdentifier); + return descendants.length > 0 + ? descendants.concat( + descendants.flatMap((descendant) => + getDescendants(relations, descendant) + ) + ) + : []; +}; + +const RelationshipGraph = ({ + data, + issueIdentifier, +}: { + data: Data; + issueIdentifier: string; +}) => { + const [showing, setShowing] = useState(false); + const [forceStrength, setForceStrength] = useState(1500); + + useEffect(() => { + const escapeListener = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setShowing(false); + } + }; + + document.addEventListener("keydown", escapeListener); + + return () => { + document.removeEventListener("keydown", escapeListener); + }; + }); + + const blockingRelations = data.relations.filter( + (relation) => relation.type === "blocks" + ); + + const ancestors = getAncestors(blockingRelations, issueIdentifier); + const descendants = getDescendants(blockingRelations, issueIdentifier); + + if (ancestors.length === 0 && descendants.length === 0) { + return

No dependencies or dependants.

; + } + + const all = [...ancestors, issueIdentifier, ...descendants]; + + let graph = create(all.length, (index) => all[index]); + + for (let relation of blockingRelations) { + if ( + graph[relation.issueIdentifier] && + graph[relation.relatedIssueIdentifier] + ) { + graph = addEdge(graph, [ + relation.issueIdentifier, + relation.relatedIssueIdentifier, + ]); + } + } + + return isCyclic(graph) ? ( +

Dependency graph is cyclic.

+ ) : showing ? ( + +
+
+ + {({ width, height }) => ( +
+ + data.issues.find((issue) => issue.identifier === identifier) + ?.title ?? identifier + } + color={(identifier) => { + const issue = data.issues.find( + (issue) => issue.identifier === identifier + ); + return issue?.state === "completed" || + issue?.state === "canceled" + ? "#999" + : identifier === issueIdentifier + ? "#000" + : ancestors.includes(identifier) + ? "#900" + : "#090"; + }} + /> +
+ )} +
+
+ + { + setForceStrength(parseInt(value, 10)); + }} + style={{ position: "absolute", left: 20, top: 20 }} + /> +
+
+ ) : ( + + ); +}; + +export default RelationshipGraph; diff --git a/package.json b/package.json index c91125c..685e0d8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,12 @@ "dependencies": { "@vanilla-extract/css": "^1.7.2", "@vanilla-extract/next-plugin": "^2.0.2", + "@visx/network": "^2.10.0", + "@visx/responsive": "^2.10.0", "@visx/scale": "^2.2.2", + "d3-force": "^3.0.0", "date-fns": "^2.29.3", + "graph-fns": "^0.3.0", "marked": "^4.0.18", "next": "12.2.5", "react": "18.2.0", @@ -22,6 +26,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "@types/d3-force": "^3.0.3", "@types/dedent": "^0.7.0", "@types/marked": "^4.0.3", "@types/node": "18.7.3", diff --git a/pages/_app.page.tsx b/pages/_app.page.tsx index 4e60123..d2c2cc8 100644 --- a/pages/_app.page.tsx +++ b/pages/_app.page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useAsync } from "react-use"; import SettingsForm from "../components/SettingsForm"; import AppContext, { Context } from "../utils/AppContext"; -import { getIssues } from "../utils/linear"; +import { getIssues, getRelations } from "../utils/linear"; import { splash } from "./_app.css"; const App = ({ Component, pageProps }: AppProps) => { @@ -18,9 +18,10 @@ const App = ({ Component, pageProps }: AppProps) => { setMissingCredentials(true); } else { const issues = await getIssues({ linearApiKey, linearTeamId }); + const relations = await getRelations({ linearApiKey, linearTeamId }); setContext({ credentials: { linearApiKey, linearTeamId }, - data: { issues }, + data: { issues, relations }, }); } }); diff --git a/pages/index.page.tsx b/pages/index.page.tsx index 7558680..aca9256 100644 --- a/pages/index.page.tsx +++ b/pages/index.page.tsx @@ -59,6 +59,9 @@ const IndexPage: NextPage = () => { {data.issues + .filter( + (issue) => issue.state === "triage" || issue.state === "backlog" + ) .map(({ id }) => id) .sort((a, b) => stats[b].priority - stats[a].priority) .map((id) => { @@ -71,7 +74,7 @@ const IndexPage: NextPage = () => { ); return issue ? ( - {issue.key} + {issue.identifier} {issue.title} {stats[id].priority.toFixed(2)} @@ -102,7 +105,9 @@ const IndexPage: NextPage = () => { } ); if (!success) { - window.alert(`Problem updating ${issue.key}.`); + window.alert( + `Problem updating ${issue.identifier}.` + ); } }} > diff --git a/pages/settings.page.tsx b/pages/settings.page.tsx index 83aaa15..5baeb7c 100644 --- a/pages/settings.page.tsx +++ b/pages/settings.page.tsx @@ -87,9 +87,9 @@ const Label = ({ case ComparisonType.Effort: return ( - {issueA?.key ?? "UNKNOWN"} is - quicker to resolve than{" "} - {issueB?.key ?? "UNKNOWN"}{" "} + {issueA?.identifier ?? "UNKNOWN"}{" "} + is quicker to resolve than{" "} + {issueB?.identifier ?? "UNKNOWN"}{" "}