1/**
2 * Guide runs this tab is waiting on, so the reader can leave and hear when one is done, and where
3 * the reader is in each guide.
4 *
5 * A run takes minutes and finishes on the server. `PullRequestGuideRunCoordinator` watches every
6 * run listed here, wherever the reader is in the app, and says when it ends. Progress is shared so
7 * a guide opened beside a new thread picks up at the same stop. Not persisted: a reload forgets
8 * both, and the guide is still there when the reader comes back.
9 */
10import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
11import { create } from "zustand";
12
13import { pullRequestReviewKey } from "../pullRequestReviewStore";
14
15export interface GuideRunWatch {
16 readonly environmentId: EnvironmentId;
17 readonly reference: PullRequestRef;
18 /** The pull request's title, for the notice. */
19 readonly title: string | null;
20 /** The guide and failure on record when the wait began; a different one means the run ended. */
21 readonly previousGuideAt: string | null;
22 readonly previousFailedAt: string | null;
23 readonly since: number;
24}
25
26/** Where a reader is in one guide, kept per guide head so any panel showing it agrees. */
27export interface GuideProgress {
28 readonly position: number;
29 /** Stops the reader has moved on from, which the rail checks off. */
30 readonly seen: ReadonlySet<number>;
31}
32
33const NO_PROGRESS: GuideProgress = { position: 0, seen: new Set() };
34
35interface GuideRunStoreState {
36 readonly watches: Readonly<Record<string, GuideRunWatch>>;
37 /** The guide on screen right now, which needs no notice when it lands. */
38 readonly shownKey: string | null;
39 /** A guide a notice asked to open; its panel switches to the Guide tab and clears this. */
40 readonly openRequestKey: string | null;
41 /** Reading progress by guide, keyed by `guideProgressKey`. */
42 readonly progress: Readonly<Record<string, GuideProgress>>;
43 readonly setProgress: (key: string, change: (current: GuideProgress) => GuideProgress) => void;
44 readonly watch: (watch: GuideRunWatch) => void;
45 readonly settle: (key: string) => void;
46 readonly setShown: (key: string | null) => void;
47 readonly requestOpen: (key: string | null) => void;
48}
49
50export function guideRunKey(environmentId: EnvironmentId, reference: PullRequestRef): string {
51 return `${environmentId}:${pullRequestReviewKey(reference)}`;
52}
53
54export const useGuideRunStore = create<GuideRunStoreState>()((set) => ({
55 watches: {},
56 shownKey: null,
57 openRequestKey: null,
58 progress: {},
59 setProgress: (key, change) =>
60 set((state) => ({
61 progress: { ...state.progress, [key]: change(state.progress[key] ?? NO_PROGRESS) },
62 })),
63 watch: (watch) =>
64 set((state) => {
65 const key = guideRunKey(watch.environmentId, watch.reference);
66 if (state.watches[key] !== undefined) return state;
67 return { watches: { ...state.watches, [key]: watch } };
68 }),
69 settle: (key) =>
70 set((state) => {
71 if (state.watches[key] === undefined) return state;
72 const { [key]: _settled, ...watches } = state.watches;
73 return { watches };
74 }),
75 setShown: (shownKey) => set({ shownKey }),
76 requestOpen: (openRequestKey) => set({ openRequestKey }),
77}));
78
79/** One guide's progress: a new head is a new tour, since its stops are not the ones read. */
80export function guideProgressKey(runKey: string, headSha: string): string {
81 return `${runKey}:${headSha}`;
82}
83
84export function useGuideProgress(key: string): GuideProgress {
85 return useGuideRunStore((state) => state.progress[key] ?? NO_PROGRESS);
86}