diff --git a/package.json b/package.json index f28a478..a7ebcc2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-use": "^17.4.0", + "swr": "^2.2.5", "uuid": "^9.0.0", "zod": "^3.21.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4a7558..0e6368a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ dependencies: react-use: specifier: ^17.4.0 version: 17.5.1(react-dom@18.3.1)(react@18.3.1) + swr: + specifier: ^2.2.5 + version: 2.2.5(react@18.3.1) uuid: specifier: ^9.0.0 version: 9.0.1 @@ -2825,6 +2828,16 @@ packages: engines: {node: '>= 0.4'} dev: true + /swr@2.2.5(react@18.3.1): + resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + client-only: 0.0.1 + react: 18.3.1 + use-sync-external-store: 1.2.2(react@18.3.1) + dev: false + /tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -2953,6 +2966,14 @@ packages: punycode: 2.3.1 dev: true + /use-sync-external-store@1.2.2(react@18.3.1): + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.3.1 + dev: false + /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true diff --git a/src/app/effort/[id]/page.tsx b/src/app/effort/[id]/page.tsx index b353c0a..6b95568 100644 --- a/src/app/effort/[id]/page.tsx +++ b/src/app/effort/[id]/page.tsx @@ -10,8 +10,7 @@ const EffortPage = ({ params }: { params: Params }) => { return ( <> Which of these issues would be{" "} diff --git a/src/app/effort/page.tsx b/src/app/effort/page.tsx index e6628e8..fb6b0d2 100644 --- a/src/app/effort/page.tsx +++ b/src/app/effort/page.tsx @@ -1,28 +1,35 @@ "use client"; -import type { NextPage } from "next"; +import weightedRandomPick from "@/utils/weightedRandomPick"; import { useRouter } from "next/navigation"; import { useContext, useEffect } from "react"; -import { v4 as uuid } from "uuid"; -import Layout from "../../components/Layout"; import CoreContext from "../../core/CoreContext"; -const EffortIndexPage: NextPage = () => { +const EffortIndexPage = () => { const { - createTournament, - state: { issueSummaries }, + state: { issueSummaries, stats }, } = useContext(CoreContext); const router = useRouter(); useEffect(() => { if (issueSummaries.length > 0) { - const id = uuid(); + const relevantIssues = issueSummaries.filter( + (issue) => + issue.state === "triage" || + issue.state === "backlog" || + issue.state === "unstarted" + ); - createTournament(id); + const id = weightedRandomPick( + [...relevantIssues.map(({ id }) => id)].sort( + (a, b) => stats[a].comparisons - stats[b].comparisons + ), + 8 + ); router.push(`/effort/${id}`); } - }, [createTournament, issueSummaries.length, router]); + }, [issueSummaries, issueSummaries.length, router, stats]); return <>Generating tournament...; }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 92d5e3d..9c8d0aa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import ComparisonValue from "../components/ComparisonValue"; import ProjectName from "../components/ProjectName"; import RelationshipGraph from "../components/RelationshipGraph"; import CoreContext from "../core/CoreContext"; +import Link from "next/link"; const IndexPage: NextPage = () => { const [sortColumn, setSortColumn] = useState<{ @@ -158,9 +159,11 @@ const IndexPage: NextPage = () => { return ( - - {issue.identifier} - + + + {issue.identifier} + + {issue.projectName ? ( diff --git a/src/components/IssueCard.tsx b/src/components/IssueCard.tsx index 471d88a..2768077 100644 --- a/src/components/IssueCard.tsx +++ b/src/components/IssueCard.tsx @@ -1,16 +1,157 @@ import styles from "./IssueCard.module.css"; +import { issueDetailSchema } from "@/linear/issueDetailSchema"; +import gql from "dedent"; import { marked } from "marked"; import React, { useContext } from "react"; +import useSWR from "swr"; import type { IssueDetail } from "../core/_types"; -import RelationshipGraph from "./RelationshipGraph"; import CoreContext from "../core/CoreContext"; import ProjectName from "./ProjectName"; +import RelationshipGraph from "./RelationshipGraph"; -type Props = { issue: IssueDetail }; +type Props = { id: string }; -const IssueCard = ({ issue }: Props) => { +const IssueCard = ({ id }: Props) => { const { state } = useContext(CoreContext); + + const { data: issue } = useSWR( + id, + async (id): Promise => { + const apiKey = window + ? window.localStorage.getItem("linear_api_key") + : null; + + if (!apiKey) throw Error("No API key"); + + const response = await fetch("https://api.linear.app/graphql", { + method: "POST", + headers: { + Authorization: apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: gql` + query IssueDetail($id: String!) { + issue(id: $id) { + id + identifier + title + description + project { + id + name + icon + } + labels { + edges { + node { + name + } + } + } + parent { + identifier + title + } + relations { + nodes { + id + type + relatedIssue { + identifier + title + } + } + } + inverseRelations { + nodes { + id + type + issue { + identifier + title + } + } + } + comments { + nodes { + id + body + user { + name + } + } + } + cycle { + number + } + estimate + state { + type + } + } + } + `, + variables: { + id, + }, + }), + }); + + if (response.status !== 200) { + throw Error(await response.text()); + } + + const { errors, data } = await response.json(); + + if (Array.isArray(errors) && errors.length > 0) { + throw Error(errors.map((error) => error.message).join("\n\n")); + } + + const { issue } = issueDetailSchema.parse(data); + + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description ?? "", + projectId: issue.project?.id, + projectName: issue.project?.name, + projectIcon: issue.project?.icon ?? undefined, + labels: issue.labels.edges.map((edge) => edge.node.name), + parentIdentifier: issue.parent?.identifier, + parentTitle: issue.parent?.title, + relations: (issue.relations?.nodes ?? []) + .map((node: any) => ({ + id: node.id, + type: node.type, + identifier: node.relatedIssue.identifier, + title: node.relatedIssue.title, + })) + .concat( + (issue.inverseRelations?.nodes ?? []).map((node: any) => ({ + id: node.id, + type: node.type === "blocks" ? "blocked-by" : node.type, + identifier: node.issue.identifier, + title: node.issue.title, + })) + ), + comments: (issue.comments?.nodes ?? []).reverse().map((comment) => ({ + id: comment.id, + body: comment.body, + author: comment.user?.name ?? "unknown", + })), + cycle: issue.cycle?.number, + estimate: issue.estimate ?? undefined, + state: issue.state.type, + }; + }, + { revalidateOnMount: true } + ); + + if (issue === undefined) return "Loading..."; + return (
diff --git a/src/core/Core.tsx b/src/core/Core.tsx index 3aaa29c..407be12 100644 --- a/src/core/Core.tsx +++ b/src/core/Core.tsx @@ -13,7 +13,6 @@ type Props = { ) => Array | Promise>; children: ReactNode; getComparisons: () => Array | Promise>; - getIssueDetail: (id: string) => IssueDetail | Promise; getIssueSummaries: () => Array | Promise>; updateIssueEstimate: (id: string, estimate: number) => void | Promise; }; @@ -30,7 +29,6 @@ const decrementPendingRequest = (state: State) => ({ const Core = ({ children, - getIssueDetail, getIssueSummaries, getComparisons, ...props @@ -63,70 +61,6 @@ const Core = ({ }); }, [getComparisons, getIssueSummaries]); - const createTournament = useCallback( - (id: string) => { - const relevantIssues = state.issueSummaries.filter( - (issue) => - issue.state === "triage" || - issue.state === "backlog" || - issue.state === "unstarted" - ); - const ids: Array = []; - - ids.push( - weightedRandomPick( - [...relevantIssues.map(({ id }) => id)].sort( - (a, b) => state.stats[a].comparisons - state.stats[b].comparisons - ), - 8 - ) - ); - - for (let i = 0; i < 5 - 1; i++) { - ids.push( - weightedRandomPick( - [...relevantIssues.map(({ id }) => id)] - .filter((x) => !ids.includes(x)) - .sort( - (a, b) => - Math.abs(state.stats[ids[0]].rating - state.stats[a].rating) - - Math.abs(state.stats[ids[0]].rating - state.stats[b].rating) - ), - 2 - ) - ); - } - - for (const id of ids) { - setState(incrementPendingRequest); - Promise.resolve(getIssueDetail(id)) - .then((issueDetail) => { - setState((current) => ({ - ...current, - issueDetails: { - ...current.issueDetails, - [id]: issueDetail, - }, - })); - }) - .finally(() => { - setState(decrementPendingRequest); - }); - } - - setState((current) => { - return { - ...current, - tournaments: { - ...current.tournaments, - [id]: ids, - }, - }; - }); - }, - [getIssueDetail, state.issueSummaries, state.stats] - ); - const addComparisons = useCallback( async ( comparisons: Array> @@ -185,7 +119,7 @@ const Core = ({ return ( {children} diff --git a/src/core/CoreContext.ts b/src/core/CoreContext.ts index 1222681..c829833 100644 --- a/src/core/CoreContext.ts +++ b/src/core/CoreContext.ts @@ -6,12 +6,10 @@ const CoreContext = createContext<{ addComparisons: ( comparisons: Array> ) => void | Promise; - createTournament: (id: string) => void; state: State; updateIssueEstimate: (id: string, estimate: number) => void | Promise; }>({ addComparisons: () => {}, - createTournament: () => {}, state: defaultState, updateIssueEstimate: () => {}, }); diff --git a/src/core/IssueComparison.tsx b/src/core/IssueComparison.tsx index d4ac4ba..9d7427c 100644 --- a/src/core/IssueComparison.tsx +++ b/src/core/IssueComparison.tsx @@ -1,37 +1,63 @@ import styles from "./IssueComparison.module.css"; import { useRouter } from "next/navigation"; -import { type ReactNode, useContext, useState } from "react"; +import { type ReactNode, useContext, useEffect, useState } from "react"; import IssueCard from "../components/IssueCard"; import CoreContext from "./CoreContext"; import type { Comparison } from "./_types"; +import weightedRandomPick from "@/utils/weightedRandomPick"; type Props = { firstButtonLabel: string; - property: "effort"; successiveButtonLabel: string; title: ReactNode; - tournamentId: string; + issueId: string; }; const IssueComparison = ({ firstButtonLabel, - property, successiveButtonLabel, title, - tournamentId, + issueId, }: Props) => { const { addComparisons, - state: { issueDetails, tournaments, pendingRequests }, + state: { issueSummaries, pendingRequests, stats }, } = useContext(CoreContext); const router = useRouter(); + const [issueIds, setIssueIds] = useState>([issueId]); const [order, setOrder] = useState>([]); - const issueIds = tournaments[tournamentId] ?? []; - const issues = issueIds.map((id) => issueDetails[id]); + useEffect(() => { + if (issueIds.length < 5) { + setIssueIds((current) => { + const relevantIssues = issueSummaries.filter( + (issue) => + issue.state === "triage" || + issue.state === "backlog" || + issue.state === "unstarted" + ); + const newIds: Array = [...current]; + + newIds.push( + weightedRandomPick( + [...relevantIssues.map(({ id }) => id)] + .filter((x) => !newIds.includes(x)) + .sort( + (a, b) => + Math.abs(stats[issueId].rating - stats[a].rating) - + Math.abs(stats[issueId].rating - stats[b].rating) + ), + 2 + ) + ); + + return newIds; + }); + } + }, [issueId, issueIds, issueSummaries, stats]); return ( <> @@ -42,13 +68,11 @@ const IssueComparison = ({ {pendingRequests > 0 ? (
{pendingRequests} pending request(s)…
) : ( - issues.map((issue) => - issue ? ( -
- -
- ) : null - ) + issueIds.map((id) => ( +
+ +
+ )) )}
@@ -95,9 +119,9 @@ const IssueComparison = ({ await addComparisons(comparisons); - router.push(`/${property}`); + router.push(`/effort`); }} - disabled={order.length !== issues.length || pendingRequests > 0} + disabled={order.length !== issueIds.length || pendingRequests > 0} > Submit diff --git a/src/core/_types.ts b/src/core/_types.ts index 1a2a06d..036156b 100644 --- a/src/core/_types.ts +++ b/src/core/_types.ts @@ -3,12 +3,10 @@ import getStats from "./getStats"; export type State = { comparisons: Array; - issueDetails: Record; issueSummaries: Array; pendingRequests: number; scales: ReturnType>; stats: ReturnType; - tournaments: Record | undefined>; }; export type Comparison = { diff --git a/src/core/defaultState.ts b/src/core/defaultState.ts index 367bd51..638ead6 100644 --- a/src/core/defaultState.ts +++ b/src/core/defaultState.ts @@ -3,12 +3,10 @@ import type { State } from "./_types"; const defaultState: State = { comparisons: [], - issueDetails: {}, issueSummaries: [], pendingRequests: 0, scales: scaleLinear(), stats: {}, - tournaments: {}, }; export default defaultState; diff --git a/src/linear/LinearApp.tsx b/src/linear/LinearApp.tsx index d855816..9d5e416 100644 --- a/src/linear/LinearApp.tsx +++ b/src/linear/LinearApp.tsx @@ -55,136 +55,6 @@ const LinearApp = ({ children }: Props) => { return response.json(); }, [teamId]); - const getIssueDetail = useCallback( - async (id: string): Promise => { - if (!apiKey) throw Error("No API key"); - - const response = await fetch("https://api.linear.app/graphql", { - method: "POST", - headers: { - Authorization: apiKey, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: gql` - query IssueDetail($id: String!) { - issue(id: $id) { - id - identifier - title - description - project { - id - name - icon - } - labels { - edges { - node { - name - } - } - } - parent { - identifier - title - } - relations { - nodes { - id - type - relatedIssue { - identifier - title - } - } - } - inverseRelations { - nodes { - id - type - issue { - identifier - title - } - } - } - comments { - nodes { - id - body - user { - name - } - } - } - cycle { - number - } - estimate - state { - type - } - } - } - `, - variables: { - id, - }, - }), - }); - - if (response.status !== 200) { - throw Error(await response.text()); - } - - const { errors, data } = await response.json(); - - if (Array.isArray(errors) && errors.length > 0) { - throw Error(errors.map((error) => error.message).join("\n\n")); - } - - const { issue } = issueDetailSchema.parse(data); - - return { - id: issue.id, - identifier: issue.identifier, - title: issue.title, - description: issue.description ?? "", - projectId: issue.project?.id, - projectName: issue.project?.name, - projectIcon: issue.project?.icon ?? undefined, - labels: issue.labels.edges.map((edge) => edge.node.name), - parentIdentifier: issue.parent?.identifier, - parentTitle: issue.parent?.title, - relations: (issue.relations?.nodes ?? []) - .map((node: any) => ({ - id: node.id, - type: node.type, - identifier: node.relatedIssue.identifier, - title: node.relatedIssue.title, - })) - .concat( - (issue.inverseRelations?.nodes ?? []).map((node: any) => ({ - id: node.id, - type: node.type === "blocks" ? "blocked-by" : node.type, - identifier: node.issue.identifier, - title: node.issue.title, - })) - ), - comments: (issue.comments?.nodes ?? []).reverse().map((comment) => ({ - id: comment.id, - body: comment.body, - author: comment.user?.name ?? "unknown", - })), - cycle: issue.cycle?.number, - estimate: issue.estimate ?? undefined, - state: issue.state.type, - }; - }, - [apiKey] - ); - const getIssueSummaries = useCallback(async (): Promise< Array > => { @@ -256,7 +126,6 @@ const LinearApp = ({ children }: Props) => {