diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 286530ae..0fff1684 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -59,6 +59,7 @@ import { Stats } from './views/Stats'; import type { ProjectsNav } from './views/Projects'; import { Search } from './views/Search'; import { Settings } from './components/Settings'; +import { JailMode } from './components/JailMode'; export type { Lens }; @@ -1172,6 +1173,7 @@ export function App() { > v1 board + { + n += 1; + const at = new Date(Date.now() - ageDays * 86400_000 - 60_000).toISOString(); + const data: PullData = { + repo: 'iFixit/ifixit', + number: n, + state: 'open', + title: `PR ${n}`, + body: '', + draft: false, + created_at: at, + updated_at: at, + closed_at: null, + merged_at: null, + mergeable: true, + difficulty: null, + additions: 10, + deletions: 5, + changed_files: 2, + milestone: { title: null, due_on: null }, + head: { ref: 'branch', sha: 'abc', repo: { owner: { login: 'iFixit' } } }, + base: { ref: 'master' }, + user: { login: 'me' }, + status: { + cr_req: ready ? 0 : 1, + qa_req: ready ? 0 : 1, + allCR: [], + allQA: [], + dev_block: [], + deploy_block: [], + commit_statuses: [], + }, + labels: [], + participants: [], + }; + return derive(data, undefined); +}; +const many = (count: number) => Array.from({ length: count }, () => pull()); + +let root: Root | null = null; +const host = document.createElement('div'); +document.body.append(host); +afterEach(() => { + act(() => root?.unmount()); + root = null; + localStorage.clear(); + setSettings({ jailMode: 'auto' }); + vi.useRealTimers(); +}); + +function render(pulls: DerivedPull[]) { + const r = (root ??= createRoot(host)); + act(() => r.render()); +} + +const why = () => document.getElementById('jail-why')?.textContent; +const listed = () => document.querySelectorAll('[role="dialog"] li').length; +const dialog = () => document.querySelector('[role="dialog"]'); +const countdown = () => document.querySelector('#jail-title span')?.textContent; +const headers = () => [...document.querySelectorAll('[role="dialog"] h3')].map(h => h.textContent); + +describe('JailMode while the cell is down', () => { + it('follows new PRs and merges live', () => { + const first = many(8); + render(first); + expect(why()).toMatch(/^8 open PRs/); + expect(listed()).toBe(8); + + // a new PR arrives over the socket + render([...first, pull()]); + expect(why()).toMatch(/^9 open PRs/); + expect(listed()).toBe(9); + + expect(countdown()).toBe('2 to go'); + + // merges bring you under the limits: it says so, then lets you go + vi.useFakeTimers(); + render(first.slice(0, 3)); + expect(why()).toBe('You’re out. Nice work.'); + expect(countdown()).toBe('Free'); + expect(listed()).toBe(0); + act(() => vi.advanceTimersByTime(2500)); + expect(dialog()).toBeNull(); + }); + + it('lifts ready PRs to the top and counts them in the why line', () => { + const ready = pull(2, { ready: true }); + render([...many(7), ready]); + expect(headers()).toEqual(['Ready to merge', 'Waiting on others']); + expect(document.querySelector('[role="dialog"] li a')?.textContent).toContain( + ready.data.title + ); + expect(why()).toBe('8 open PRs. Parole at 7. Merge your ready one and you’re out.'); + }); + + it('only shows the badge when PR jail is manual', () => { + setSettings({ jailMode: 'manual' }); + render(many(9)); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + const badge = document.querySelector('[aria-label^="PR jail"]'); + expect(badge).not.toBeNull(); + act(() => badge?.click()); + expect(why()).toMatch(/^9 open PRs/); + }); + + it('stays away, badge and all, when PR jail is off', () => { + setSettings({ jailMode: 'off' }); + render(many(9)); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.querySelector('[aria-label^="PR jail"]')).toBeNull(); + }); + + it('lets you out only when the button is held down, by key or pointer', () => { + vi.useFakeTimers(); + render(many(8)); + const snooze = [ + ...document.querySelectorAll('[role="dialog"] button'), + ].find(b => b.textContent?.includes('Hold to get back to work')); + // it has focus from the start, so the keyboard can hold it right away + expect(document.activeElement).toBe(snooze); + const key = (type: string, k = 'Enter') => + act(() => snooze?.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true }))); + // a tap of Enter does nothing but say to hold it; neither does Esc + key('keydown'); + act(() => vi.advanceTimersByTime(200)); + key('keyup'); + expect(snooze?.textContent).toBe('Hold it down'); + act(() => vi.advanceTimersByTime(3000)); + act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))); + expect(dialog()).not.toBeNull(); + // holding Space the full two seconds turns the key, then it opens + key('keydown', ' '); + act(() => vi.advanceTimersByTime(2000)); + act(() => vi.advanceTimersByTime(700)); + expect(dialog()).toBeNull(); + }); + + it('slides a merged PR out of the list as the countdown ticks', () => { + vi.useFakeTimers(); + const nine = many(9); + render(nine); + expect(countdown()).toBe('2 to go'); + render(nine.slice(1)); + expect(countdown()).toBe('1 to go'); + // still there for the slide, then gone + expect(document.querySelectorAll('.jail-row-leave')).toHaveLength(1); + expect(listed()).toBe(9); + act(() => vi.advanceTimersByTime(450)); + expect(listed()).toBe(8); + }); + + it('copies a nudge listing the PRs waiting on others', async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.assign(navigator, { clipboard: { writeText } }); + const first = pull(3); + render([first, ...many(7)]); + const copy = [...document.querySelectorAll('[role="dialog"] button')].find( + b => b.textContent === 'Copy a nudge' + ); + await act(async () => copy?.click()); + const text = (writeText.mock.calls[0] as unknown as [string])[0]; + expect(text.split('\n')).toHaveLength(9); + expect(text).toContain( + `• ${first.data.title} https://github.com/iFixit/ifixit/pull/${first.data.number}` + ); + expect(copy?.textContent).toBe('Copied'); + }); + + it('lets a screen reader or voice control out with a lone click', () => { + render(many(8)); + const hold = [...document.querySelectorAll('[role="dialog"] button')].find( + b => b.textContent?.includes('Hold to get back to work') + ); + // a mouse click carries detail 1 and does nothing + act(() => hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))); + expect(dialog()).not.toBeNull(); + // assistive tech sends a click with no pointer or key behind it + act(() => hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))); + expect(dialog()).toBeNull(); + }); + + it('keeps Esc from reaching anything behind it', () => { + render(many(8)); + let reached = false; + const behind = (e: KeyboardEvent) => { + if (!e.defaultPrevented) reached = true; + }; + document.addEventListener('keydown', behind); + act(() => { + document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + }); + document.removeEventListener('keydown', behind); + expect(reached).toBe(false); + expect(dialog()).not.toBeNull(); + }); + + it('zeroes the last showing once you are under the limits, but keeps its time', () => { + vi.useFakeTimers(); + const nine = many(9); + render(nine); + const shownAt = JSON.parse(localStorage.getItem('pd2.jail') ?? 'null').at; + render(nine.slice(0, 3)); + expect(JSON.parse(localStorage.getItem('pd2.jail') ?? 'null')).toEqual({ + at: shownAt, + count: 0, + over: [], + }); + }); + + it('going back over within 4 hours waits out the rest of them', () => { + const nine = many(9); + render(nine); + act(() => + document + .querySelector('[data-hold]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 })) + ); + expect(dialog()).toBeNull(); + render(nine.slice(0, 3)); // under: the record zeroes + render([...nine, pull()]); // over again, minutes later + expect(dialog()).toBeNull(); + }); + + it('board hotkeys stand down while it is open', async () => { + const { modalOpen } = await import('../hooks'); + render(many(8)); + expect(modalOpen()).toBe(true); + }); + + it('opens the preview every time Settings asks', () => { + render(many(2)); + expect(dialog()).toBeNull(); + act(() => window.dispatchEvent(new Event('pd2:preview-jail'))); + expect(dialog()).not.toBeNull(); + }); + + it('a quick Space tap is not a lone click, even where the browser clicks on keyup', () => { + render(many(8)); + const hold = [...document.querySelectorAll('[role="dialog"] button')].find( + b => b.textContent?.includes('Hold to get back to work') + ); + act(() => { + hold?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + hold?.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })); + // Firefox's keyup click, with no pointer behind it + hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 })); + }); + expect(dialog()).not.toBeNull(); + }); + + it('keeps the last showing while it doesn’t know who you are', () => { + localStorage.setItem('pd2.jail', JSON.stringify({ at: Date.now(), count: 9, over: [] })); + const r = (root ??= createRoot(host)); + act(() => r.render()); + expect(localStorage.getItem('pd2.jail')).not.toBeNull(); + expect(dialog()).toBeNull(); + }); + + it('stays down when another window showed it a moment ago', () => { + // another window wrote the record after this one loaded + const r = (root ??= createRoot(host)); + act(() => + r.render() + ); + localStorage.setItem('pd2.jail', JSON.stringify({ at: Date.now(), count: 8, over: [] })); + act(() => r.render()); + expect(dialog()).toBeNull(); + }); + + it('waits while another dialog is open', () => { + const other = document.createElement('div'); + other.setAttribute('aria-modal', 'true'); + document.body.append(other); + render(many(9)); + expect(dialog()).toBeNull(); + other.remove(); + }); + + it('a drop ends a drag even when the dragged row left the page', () => { + const row = document.createElement('div'); + document.body.append(row); + row.dispatchEvent(new Event('dragstart', { bubbles: true })); + row.remove(); // moved to another lane: its dragend never reaches the page + document.body.dispatchEvent(new Event('drop', { bubbles: true })); + render(many(9)); + expect(dialog()).not.toBeNull(); + }); +}); diff --git a/frontend-v2/src/components/JailMode.tsx b/frontend-v2/src/components/JailMode.tsx new file mode 100644 index 00000000..c690e029 --- /dev/null +++ b/frontend-v2/src/components/JailMode.tsx @@ -0,0 +1,808 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Lock } from 'lucide-react'; +import type { DerivedPull } from '../../../shared/model/status'; +import { githubUrl } from '../../../shared/format'; +import { rowNote, rowWord } from '../model/actions'; +import { STAGE_WORDS } from '../model/stage'; +import { + jailCase, + jailDue, + jailGroups, + jailRecord, + parseJailRecord, + JAIL_KEY, + type JailCase, +} from '../model/jail'; +import { useSettings } from '../settings'; +import { readStorage, writeStorage } from '../storage'; +import { HeaderIconButton, QuietButton, RepoRef } from './bits'; +import { modalOpen } from '../hooks'; +import { GroupHeader } from './Lane'; +import { CiGlyph } from './pips'; + +/** + * PR jail: when your own open PRs pass a limit (model/jail.ts), a modal pops + * up listing them, and comes back only when that gets worse (jailDue). It + * has no close button and Esc does nothing: you hold Snooze down for a + * couple of seconds (a little work on purpose), or get under the limits and + * it lets you go. While you're over, a header badge counts down what's left + * and reopens it. `#jail=1` in the URL forces it, any time, for a preview. + * The jailMode setting picks: pop up (auto), the badge alone (manual), or off. + */ + +// whether a mouse button or finger is down, or a native drag is under way: +// the jail never pops up mid-drag (a Roadmap bar or row, say) +let pressing = false; +let dragging = false; +const pointerBusy = () => pressing || dragging; +if (typeof document !== 'undefined') { + // the main button only: a right-click's context menu swallows its + // pointerup and would leave this stuck on + // a new press also means no drag is running, whatever became of the last + document.addEventListener( + 'pointerdown', + e => { + pressing = e.button === 0; + dragging = false; + }, + true + ); + // a touch that turns into a scroll, or a press that turns into a native + // drag, ends with pointercancel; the drag is tracked on its own + for (const end of ['pointerup', 'pointercancel', 'contextmenu'] as const) + document.addEventListener(end, () => (pressing = false), true); + document.addEventListener('dragstart', () => (dragging = true), true); + // dragend goes to the dragged node, which may have left the page by then + // (a Roadmap plan moved to another lane), so the drop counts as the end too + for (const end of ['dragend', 'drop'] as const) + document.addEventListener(end, () => (dragging = false), true); + window.addEventListener('blur', () => (pressing = dragging = false)); +} + +const hasPreviewFlag = () => new URLSearchParams(location.hash.slice(1)).get('jail') === '1'; + +/** Renders the header badge in place, and the cell over the page. */ +export function JailMode({ + pulls, + me, + extraBots, + initialized, +}: { + pulls: DerivedPull[]; + me: string; + extraBots: ReadonlySet; + initialized: boolean; +}) { + const settings = useSettings(); + // read on the first render: the app rewrites the hash right after and + // drops the unknown param + const [preview, setPreview] = useState(hasPreviewFlag); + // which jail is open, not a copy of it: the list below is read live, so a + // merge or a new PR shows up while it's open + const [shown, setShown] = useState<'real' | 'preview' | null>(null); + // the last showing, kept here too so blocked storage still holds it for + // this page load; another tab's showing arrives by the storage event + const record = useRef(parseJailRecord(readStorage(JAIL_KEY))); + + const found = useMemo( + () => + // no case until we know who you are: an empty `me` (a failed whoami) + // would read as "under the limits" and wipe the last showing + initialized && me && settings.jailMode !== 'off' + ? jailCase(pulls, me, extraBots, { + maxOpen: settings.jailMaxOpen, + maxDays: settings.jailMaxDays, + countDrafts: settings.jailCountDrafts, + }) + : null, + [initialized, pulls, me, extraBots, settings] + ); + + // under the limits again: zero what the last showing counted, so going + // over later is worse than it (a fresh offense), but keep when it showed, + // so it still comes back at most every 4 hours + useEffect(() => { + const last = record.current; + if (!initialized || !me || found || settings.jailMode === 'off' || !last) return; + if (!last.count && !last.over.length) return; + record.current = { at: last.at, count: 0, over: [] }; + writeStorage(JAIL_KEY, JSON.stringify(record.current)); + }, [initialized, me, found, settings.jailMode]); + + // the latest case, for closing: the button's hold started seconds ago, and + // what you saw last is what the next showing compares against + const latest = useRef(found); + latest.current = found; + + // open PRs on the whole board, so a row leaving the jail's list only slides + // out as merged when it really closed (not when the rule switched) + const openIds = useMemo( + () => new Set(pulls.filter(p => p.data.state === 'open').map(pullId)), + [pulls] + ); + + const remember = (jail: JailCase) => { + record.current = jailRecord(jail, Date.now()); + writeStorage(JAIL_KEY, JSON.stringify(record.current)); + }; + const show = (jail: JailCase) => { + remember(jail); + setShown('real'); + }; + + useEffect(() => { + const onHash = () => { + if (hasPreviewFlag()) setPreview(true); + }; + // Settings' "Preview PR jail": an event, since setting the same hash + // twice fires nothing + const onPreview = () => setPreview(true); + window.addEventListener('pd2:preview-jail', onPreview); + const onStorage = (e: StorageEvent) => { + if (e.key === JAIL_KEY) record.current = parseJailRecord(e.newValue); + }; + window.addEventListener('hashchange', onHash); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener('hashchange', onHash); + window.removeEventListener('pd2:preview-jail', onPreview); + window.removeEventListener('storage', onStorage); + }; + }, []); + + // the preview never writes the record, so it can't stand in for a real one + useEffect(() => { + if (!initialized || !preview) return; + setPreview(false); + setShown('preview'); + }, [initialized, preview]); + + // checked on load, on every data change, and when the tab comes back; a + // hidden tab or a focused field waits for the next one, so it never lands + // mid-typing or out of sight + useEffect(() => { + if (!found || shown || settings.jailMode !== 'auto') return; + const check = () => { + if (document.visibilityState === 'hidden') return; + // another dialog is up (Settings), or a drag is under way: the jail + // would land on top of it, and swallow the Esc that cancels a drag + if (modalOpen() || pointerBusy()) return; + if (document.activeElement?.matches('input, textarea, select')) return; + // read the record fresh: a second visible window may have shown it + // a moment ago, before its storage event reached this one + record.current = parseJailRecord(readStorage(JAIL_KEY)) ?? record.current; + if (!jailDue(found, record.current, Date.now())) return; + show(found); + }; + check(); + document.addEventListener('visibilitychange', check); + window.addEventListener('focus', check); + return () => { + document.removeEventListener('visibilitychange', check); + window.removeEventListener('focus', check); + }; + }, [found, shown, settings.jailMode]); + + // you got under the limits while it was up: it says so, then lets you go + const freed = shown === 'real' && !found; + useEffect(() => { + if (!freed) return; + const t = window.setTimeout(() => setShown(null), FREED_MS); + return () => clearTimeout(t); + }, [freed]); + + return ( + <> + {found && ( + show(found)} + className="relative" + > + + {found.toGo > 9 ? '9+' : found.toGo} + + + )} + {shown && + createPortal( + { + // what you saw last is what the next showing compares + // against, so PRs opened while it was up don't re-drop it + if (shown === 'real' && latest.current) remember(latest.current); + setShown(null); + }} + />, + document.body + )} + + ); +} + +/** How long "You're out" shows before the jail closes on its own. */ +const FREED_MS = 2500; + +/** Merged or closed your way under the limits while it was up. */ +const FREE: JailCase = { + pulls: [], + why: 'You’re out. Nice work.', + count: 0, + over: [], + toGo: 0, +}; + +/** The preview when you're not in jail: your PRs as jail counts them (any + * open one trips a limit of 0), or the board's oldest when you have none, + * so there's something to look at. */ +function sampleCase( + pulls: DerivedPull[], + me: string, + extraBots: ReadonlySet, + settings: { jailMaxDays: number; jailCountDrafts: boolean } +): JailCase { + const mine = jailCase(pulls, me, extraBots, { + maxOpen: 0, + maxDays: settings.jailMaxDays, + countDrafts: settings.jailCountDrafts, + }); + if (mine) return { ...mine, why: 'Preview: your open PRs, as jail would list them' }; + return { + pulls: pulls + .filter(p => p.data.state === 'open') + .sort((a, b) => b.ageDays - a.ageDays) + .slice(0, 6), + why: 'Preview: these are sample PRs from the board', + count: 0, + over: [], + toGo: 0, + }; +} + +/** How long "Hold to get back to work" has to be held down. */ +const HOLD_MS = 2000; +/** How long the open lock shows before the jail closes. */ +const OPEN_MS = 700; +/** How long a merged or closed row takes to leave the list. */ +const LEAVE_MS = 450; + +/** The modal over the board: a centered card on a desktop, a sheet up from + * the bottom on a phone. Its header and its one button stay put while the + * list scrolls between them. No close button and no Esc: the way out besides + * getting under the limits is holding the button down for a couple of + * seconds, by pointer, finger, Enter or Space. */ +function JailModal({ + jail, + me, + maxDays, + openIds, + onDone, +}: { + jail: JailCase; + me: string; + maxDays: number; + openIds: ReadonlySet; + onDone: () => void; +}) { + const rootRef = useRef(null); + const free = jail === FREE; + + // "You're out" unmounts the focused button: keep focus in the dialog + useEffect(() => { + if (free) rootRef.current?.focus({ preventScroll: true }); + }, [free]); + + useEffect(() => { + const root = rootRef.current; + // focus goes back where it was (the header lock, say) when it closes + const before = document.activeElement as HTMLElement | null; + // focus starts on the button, so holding Enter or Space works right + // away; the dialog itself when it's showing "You're out" + const hold = root?.querySelector('[data-hold]'); + (hold ?? root)?.focus({ preventScroll: true }); + // Esc doesn't close it (holding the button does); Tab stays inside + const onKey = (e: KeyboardEvent) => { + // Esc does nothing here, and must not reach a dialog behind this one + // (Settings) either: listening in the capture phase runs first + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + } + if (e.key !== 'Tab' || !root) return; + const focusables = root.querySelectorAll('a[href], button'); + if (!focusables.length) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } else if (!root.contains(document.activeElement)) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKey, true); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey, true); + document.body.style.overflow = prevOverflow; + before?.focus?.({ preventScroll: true }); + }; + }, []); + + return ( +
+
+
+ +
+

+ PR jail + {/* the countdown to freedom: ticks down live as you merge + or close */} + + {free ? 'Free' : `${jail.toGo} to go`} + +

+

+ {jail.why} +

+
+
+ {!free && ( + <> + {/* the board's own sticky group headers, pinned to the top + of this list on the card's color instead of under the + app header on the canvas */} +
+ +
+ + + )} +
+
+ ); +} + +/** The one button: hold it down for HOLD_MS, by pointer, finger, Enter or + * Space. The key in the lock turns a quarter turn as you hold and the ring + * around it fills, both off the same clock; letting go early springs them + * back, and a quick click says to hold it. At the end the lock opens and + * the jail closes. */ +function HoldToLeave({ onDone }: { onDone: () => void }) { + const [holding, setHolding] = useState(false); + const [opened, setOpened] = useState(false); + // a click too short to be a hold: the label teaches the gesture + const [tapped, setTapped] = useState(false); + const timer = useRef(undefined); + const tapTimer = useRef(undefined); + const since = useRef(0); + // when a key was last let go: Firefox clicks a button on Space's keyup, + // and that click must not pass for assistive tech's lone click + const keyUpAt = useRef(0); + const start = () => { + if (timer.current !== undefined || opened) return; + since.current = Date.now(); + setHolding(true); + setTapped(false); + timer.current = window.setTimeout(() => { + setOpened(true); + timer.current = window.setTimeout(onDone, OPEN_MS); + }, HOLD_MS); + }; + const stop = () => { + if (opened || timer.current === undefined) return; + clearTimeout(timer.current); + timer.current = undefined; + setHolding(false); + if (Date.now() - since.current < 500) { + setTapped(true); + clearTimeout(tapTimer.current); + tapTimer.current = window.setTimeout(() => setTapped(false), 2000); + } + }; + useEffect( + () => () => { + clearTimeout(timer.current); + clearTimeout(tapTimer.current); + }, + [] + ); + const isPress = (key: string) => key === 'Enter' || key === ' '; + // the ring and the key share one timing: fill and turn while held, snap + // back when let go + const sweep = holding ? `${HOLD_MS}ms linear` : '150ms ease-out'; + return ( + + ); +} + +const pullId = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; + +/** The PRs that just left `pulls` (merged or closed), kept for LEAVE_MS so + * their rows can slide out instead of vanishing. */ +function useLeaving(pulls: DerivedPull[], openIds: ReadonlySet): DerivedPull[] { + const last = useRef(new Map()); + const [leaving, setLeaving] = useState([]); + const timers = useRef([]); + useEffect(() => { + const now = new Map(pulls.map(p => [pullId(p), p])); + // gone from the list and closed on the board; one that only stopped + // counting (the rule switched, or it went draft) just leaves + const gone = [...last.current] + .filter(([id]) => !now.has(id) && !openIds.has(id)) + .map(([, p]) => p); + last.current = now; + if (!gone.length) return; + setLeaving(l => [...l, ...gone]); + timers.current.push( + window.setTimeout(() => setLeaving(l => l.filter(p => !gone.includes(p))), LEAVE_MS) + ); + // keyed on pulls alone: openIds changes with every board update, and + // it's only read to tell a close from a rule switch + }, [pulls]); + useEffect(() => () => timers.current.forEach(clearTimeout), []); + // one that came back (reopened) is just a row again + return leaving.filter(p => !pulls.some(q => pullId(q) === pullId(p))); +} + +/** Slack-ready text asking for the reviews your waiting PRs need: each + * title, its link, and who it waits on. */ +function nudgeText(rows: { pull: DerivedPull }[], me: string): string { + return [ + 'A few of my PRs are waiting on a review or a stamp:', + ...rows.map(({ pull: p }) => { + const who = rowNote(p, me).context; + return `• ${p.data.title} ${githubUrl(p.data.repo, p.data.number)}${ + who ? ` (${who})` : '' + }`; + }), + ].join('\n'); +} + +/** Copies nudgeText, and says whether it worked. */ +function CopyNudge({ rows, me }: { rows: { pull: DerivedPull }[]; me: string }) { + const [copied, setCopied] = useState<'copied' | 'failed' | null>(null); + const done = (result: 'copied' | 'failed') => { + setCopied(result); + window.setTimeout(() => setCopied(null), 3000); + }; + return ( + { + if (!navigator.clipboard) return done('failed'); + navigator.clipboard.writeText(nudgeText(rows, me)).then( + () => done('copied'), + () => done('failed') + ); + }} + > + {copied === 'copied' ? 'Copied' : copied === 'failed' ? 'Couldn’t copy' : 'Copy a nudge'} + + ); +} + +/** Your PRs, the way My work groups them, with the ready ones on top: the + * quickest way out first, each group under the board's own header with a + * word on what to do, oldest first. Someone else's PRs (the preview's + * fallback) list flat, since their groups would be a reviewer's. */ +function JailList({ + pulls, + me, + maxDays, + openIds, +}: { + pulls: DerivedPull[]; + me: string; + maxDays: number; + openIds: ReadonlySet; +}) { + const leaving = useLeaving(pulls, openIds); + const gone = new Set(leaving.map(pullId)); + const all = [...pulls, ...leaving]; + const mine = all.every(p => p.data.user.login === me); + const groups = mine + ? jailGroups(all, me) + : { ready: [], move: [], waiting: all.map(pull => ({ pull })) }; + const live = (rows: { pull: DerivedPull }[]) => rows.filter(r => !gone.has(pullId(r.pull))); + const sections = [ + { title: STAGE_WORDS.ready, sub: 'Merge these first', rows: groups.ready, ready: true }, + { title: 'Your move', sub: 'Waiting on you', rows: groups.move }, + { + title: 'Waiting on others', + sub: 'Nudge them, or close what you don’t need', + rows: groups.waiting, + nudge: true, + }, + ].filter(g => g.rows.length); + return ( +
+ {sections.map(g => ( +
+ {mine && ( + + ) : undefined + } + /> + )} +
    + {g.rows.map(({ pull }) => ( + + ))} +
+
+ ))} +
+ ); +} + +/** One PR: the title to GitHub and its age, then the move and what it's + * waiting on. */ +function JailRow({ + pull: p, + me, + maxDays, + ready, + mine, + leaving, +}: { + pull: DerivedPull; + me: string; + maxDays: number; + ready: boolean; + mine: boolean; + /** merged or closed: it slides out */ + leaving?: boolean; +}) { + const word = mine && !ready ? rowWord(p, me) : null; + const context = mine ? rowNote(p, me).context : null; + // a do row names the move, then the detail; a wait row's detail already + // says who it waits on ("waiting on bob to re-stamp"), so the word only + // stands in when there's no detail + const move = word?.kind === 'do' ? word.word : null; + const detail = context ?? (word?.kind === 'wait' ? word.word : null); + const old = p.ageDays > maxDays; + return ( +
  • + + + {p.data.title} ↗ + + + {p.ageDays ? `${p.ageDays} days` : 'today'} + + + + + {move && ( + + {move} + + )} + {!ready && ( + <> + + {/* the glyph is aria-hidden; say what it shows */} + {p.ci === 'failing' && CI failing} + {p.ci === 'pending' && CI running} + + )} + {detail && {detail}} + +
  • + ); +} + +/** The inmate: a sad robot behind bars, drawn in the board's own line + * weights and color tokens so it themes with everything else. */ +function SadRobot() { + return ( + + {/* a drooping antenna */} + + + {/* head, and the face on its screen */} + + + + + + {/* striped prison body */} + + + {/* the bars, in front of him */} + + + ); +} diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 72b31dd0..0728d796 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -134,6 +134,8 @@ export function Settings({ useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { + // a dialog above this one (PR jail) handled it + if (e.defaultPrevented) return; if (e.key === 'Escape') setOpen(false); // aria-modal promises a focus trap; without this, Tab walks out of // the dialog into the live board behind the scrim @@ -328,6 +330,73 @@ export function Settings({ onChange={ageDisplay => set({ ageDisplay })} /> + + set({ jailMode })} + /> + + + set({ jailMaxOpen })} + /> + + + set({ jailMaxDays })} + /> + + + set({ jailCountDrafts: v === 'on' })} + /> + + + { + // close Settings first so the jail isn't stacked on it + setOpen(false); + window.dispatchEvent(new Event('pd2:preview-jail')); + }} + > + Preview PR jail + + ; + const pendingCount = checks.filter(c => c.data.state === 'pending').length; + if (pendingCount) + return ( + + ); + return passed ? : null; +} + /** * CI in the sign-off family: the machine is a reviewer, so it wears the same * label + circle-mark anatomy as CR and QA. Only a failure earns ink at rest @@ -91,28 +117,7 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { CI - {/* one circle (styles.css .ci-ring/.ci-fail): a red ring with - an X = failing (the fraction lives in the popover; a 1-of-25 - failure must read as loudly as 25-of-25), the slate ring - sweeping closed = running (the sweep is the completed share, - v1's grey section reborn), and a closed green ring revealed - on row hover = passed (no draw-on-reveal: animation means a - state CHANGED, and hovering isn't a change). */} - {failing > 0 ? ( - - ) : pending ? ( - - ) : ( - - )} + )} > diff --git a/frontend-v2/src/components/useRowKeys.ts b/frontend-v2/src/components/useRowKeys.ts index badbd9f1..60220fb9 100644 --- a/frontend-v2/src/components/useRowKeys.ts +++ b/frontend-v2/src/components/useRowKeys.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { modalOpen } from '../hooks'; /** * j and k move between a list's rows, the way they move between PRs on the @@ -26,6 +27,8 @@ export function useRowKeys(row: string, target: string) { }; const onKey = (e: KeyboardEvent) => { if ((e.key !== 'j' && e.key !== 'k') || e.metaKey || e.ctrlKey || e.altKey) return; + // a modal dialog is open: rows behind it are out of reach + if (modalOpen()) return; const t = e.target as HTMLElement; if (['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable) return; // drawn rows only: one in a closed fold has no box to land on diff --git a/frontend-v2/src/hooks.ts b/frontend-v2/src/hooks.ts index 84e60cc1..b9cec02b 100644 --- a/frontend-v2/src/hooks.ts +++ b/frontend-v2/src/hooks.ts @@ -1,10 +1,14 @@ import { type RefObject, useEffect, useLayoutEffect } from 'react'; +/** A modal dialog is open (PR jail, Settings): the board's own keys stand + * down, so `/` or `j` can't move focus behind the scrim. */ +export const modalOpen = () => document.querySelector('[aria-modal="true"]') != null; + /** A key press the board's keys leave alone: one a view's own keys - * (components/useRowKeys.ts) already took, one typed into a field, or one - * with a modifier, which belongs to the browser. */ + * (components/useRowKeys.ts) already took, one typed into a field, one with + * a modifier, which belongs to the browser, or one while a modal is open. */ function notOurs(e: KeyboardEvent): boolean { - if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return true; + if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || modalOpen()) return true; const t = e.target as HTMLElement; return ['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable; } diff --git a/frontend-v2/src/model/jail.test.ts b/frontend-v2/src/model/jail.test.ts new file mode 100644 index 00000000..0069a243 --- /dev/null +++ b/frontend-v2/src/model/jail.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { + JAIL_COOLDOWN_MS, + jailCase, + jailDue, + jailRecord, + parseJailRecord, + type JailPull, +} from './jail'; + +const LIMITS = { maxOpen: 7, maxDays: 14 }; +const NO_BOTS: ReadonlySet = new Set(); + +let n = 0; +function pull( + login: string, + ageDays: number, + o: { draft?: boolean; deployBlockedBy?: string[]; ready?: boolean; cryo?: boolean } = {} +): JailPull { + n += 1; + return { + data: { + repo: 'iFixit/ifixit', + number: n, + title: `PR ${n}`, + draft: o.draft ?? false, + state: 'open', + user: { login }, + }, + ageDays, + deployBlockedBy: o.deployBlockedBy ?? [], + status: o.ready ? 'ready' : 'needs_cr', + cryo: o.cryo ?? false, + }; +} + +const many = (count: number, login = 'me', ageDays = 1) => + Array.from({ length: count }, () => pull(login, ageDays)); + +describe('jailCase', () => { + // [open, ready] -> the why line; limit 7 + it.each([ + [20, 0, '20 open PRs. Parole at 7. Close 13 to get out.'], + [20, 3, '20 open PRs. Parole at 7. Merge your 3 ready ones and close 10 more.'], + [10, 3, '10 open PRs. Parole at 7. Merge your 3 ready ones and you’re out.'], + [12, 6, '12 open PRs. Parole at 7. Merge 5 of your 6 ready ones and you’re out.'], + [8, 1, '8 open PRs. Parole at 7. Merge your ready one and you’re out.'], + [9, 1, '9 open PRs. Parole at 7. Merge your ready one and close 1 more.'], + ])('with %i open and %i ready, points at the ready ones first', (open, ready, why) => { + const pulls = [ + ...many(open - ready), + ...Array.from({ length: ready }, () => pull('me', 1, { ready: true })), + ]; + expect(jailCase(pulls, 'me', NO_BOTS, LIMITS)?.why).toBe(why); + }); + + it('never counts a parked PR as ready', () => { + const parked = pull('me', 1, { ready: true, cryo: true }); + expect(jailCase([...many(7), parked], 'me', NO_BOTS, LIMITS)?.why).toBe( + '8 open PRs. Parole at 7. Close 1 to get out.' + ); + }); + + it('won’t promise you’re out while an old PR would still hold you', () => { + const old = pull('me', 40); + const pulls = [...many(6), old, pull('me', 1, { ready: true })]; + expect(jailCase(pulls, 'me', NO_BOTS, LIMITS)?.why).toBe( + `8 open PRs. Parole at 7. Merge your ready one to get under 7. #${old.data.number} is still past 14 days.` + ); + // an old PR that's ready leaves with the merge, so you are out + const oldReady = [...many(7), pull('me', 40, { ready: true })]; + expect(jailCase(oldReady, 'me', NO_BOTS, LIMITS)?.why).toBe( + '8 open PRs. Parole at 7. Merge your ready one and you’re out.' + ); + }); + + it('counts what’s left to close, the old ones included', () => { + // 9 open (2 over the limit) and 3 old: closing the 3 old ones frees you + const pulls = [...many(6), pull('me', 20), pull('me', 30), pull('me', 40)]; + expect(jailCase(pulls, 'me', NO_BOTS, LIMITS)?.toGo).toBe(3); + // 12 open (5 over) and 1 old: 5 to go, the old one among them + expect(jailCase([...many(11), pull('me', 20)], 'me', NO_BOTS, LIMITS)?.toGo).toBe(5); + }); + + it('says merge when the one old PR is ready, and counts several', () => { + const old = pull('me', 30, { ready: true }); + expect(jailCase([old], 'me', NO_BOTS, LIMITS)?.why).toBe( + `#${old.data.number} has been open 30 days and it’s ready. Merge it to get out.` + ); + expect(jailCase([pull('me', 20), pull('me', 15)], 'me', NO_BOTS, LIMITS)?.why).toBe( + '2 PRs open past 14 days. Merge or close them to get out.' + ); + }); + + it('stays free at the open-PR limit', () => { + expect(jailCase(many(7), 'me', NO_BOTS, LIMITS)).toBeNull(); + }); + + it('jails past the open-PR limit, listing them oldest first', () => { + const pulls = [...many(7), pull('me', 9)]; + const c = jailCase(pulls, 'me', NO_BOTS, LIMITS); + expect(c?.pulls).toHaveLength(8); + expect(c?.pulls[0].ageDays).toBe(9); + expect(c?.why).toBe('8 open PRs. Parole at 7. Close 1 to get out.'); + expect(c?.count).toBe(8); + expect(c?.toGo).toBe(1); + }); + + it('jails one PR past the age limit, and lists only the old ones', () => { + const old = pull('me', 21); + const c = jailCase([pull('me', 2), old, pull('me', 14)], 'me', NO_BOTS, LIMITS); + expect(c?.pulls).toEqual([old]); + expect(c?.why).toBe( + `#${old.data.number} has been open 21 days. Parole at 14 days. Merge or close it to get out.` + ); + expect(c?.count).toBe(3); + expect(c?.over).toEqual([`iFixit/ifixit#${old.data.number}`]); + expect(c?.toGo).toBe(1); + }); + + it('ignores other people’s PRs', () => { + expect( + jailCase([...many(9, 'someone'), pull('someone', 90)], 'me', NO_BOTS, LIMITS) + ).toBeNull(); + }); + + it('ignores drafts unless they count', () => { + const drafts = Array.from({ length: 9 }, () => pull('me', 90, { draft: true })); + expect(jailCase(drafts, 'me', NO_BOTS, LIMITS)).toBeNull(); + const counted = jailCase(drafts, 'me', NO_BOTS, { ...LIMITS, countDrafts: true }); + expect(counted?.pulls).toHaveLength(9); + }); + + it('ignores bots, by suffix and by the config list', () => { + expect(jailCase(many(9, 'dependabot[bot]'), 'dependabot[bot]', NO_BOTS, LIMITS)).toBeNull(); + expect(jailCase(many(9, 'renovate'), 'renovate', new Set(['renovate']), LIMITS)).toBeNull(); + }); + + it('ignores PRs held for a deploy', () => { + const held = Array.from({ length: 9 }, () => pull('me', 90, { deployBlockedBy: ['qa'] })); + expect(jailCase(held, 'me', NO_BOTS, LIMITS)).toBeNull(); + }); +}); + +describe('jailDue', () => { + const HOUR = 60 * 60 * 1000; + const T = 1_000_000_000_000; + const over = (pulls: JailPull[]) => jailCase(pulls, 'me', NO_BOTS, LIMITS); + const recordAt = (pulls: JailPull[], at: number) => { + const c = over(pulls); + if (!c) throw new Error('expected to be over a limit'); + return jailRecord(c, at); + }; + const eight = many(8); + const shownAt = recordAt(eight, T); + + it('never shows while you are under the limits', () => { + expect(jailDue(over(many(7)), null, T)).toBe(false); + }); + + it('shows the first time', () => { + expect(jailDue(over(eight), null, T)).toBe(true); + }); + + it('does not re-show on a refresh', () => { + expect(jailDue(over(eight), shownAt, T + 1000)).toBe(false); + }); + + it('does not re-show within 4 hours, even when it got worse', () => { + expect(jailDue(over([...eight, pull('me', 1)]), shownAt, T + 3 * HOUR)).toBe(false); + }); + + it('re-shows after 4 hours when you have more open PRs', () => { + expect(jailDue(over([...eight, pull('me', 1)]), shownAt, T + JAIL_COOLDOWN_MS)).toBe(true); + }); + + it('does not re-show after 4 hours when it is the same or better', () => { + expect(jailDue(over(eight), shownAt, T + 24 * HOUR)).toBe(false); + // closing two then opening one is still a net gain of room + const fewer = recordAt([...eight, ...many(2)], T); + expect(jailDue(over([...eight, pull('me', 1)]), fewer, T + 24 * HOUR)).toBe(false); + }); + + it('re-shows after 4 hours when a PR newly crosses the age limit', () => { + const old = pull('me', 20); + const pulls = [pull('me', 1), old]; + const last = recordAt(pulls, T); + expect(jailDue(over(pulls), last, T + 24 * HOUR)).toBe(false); + // same count: the young one aged past 14 days + const aged = { ...pulls[0], ageDays: 15 }; + expect(jailDue(over([aged, old]), last, T + 24 * HOUR)).toBe(true); + }); + + it('ignores a missing or unreadable record (and the old daily key)', () => { + expect(parseJailRecord(null)).toBeNull(); + expect(parseJailRecord('2026-10-09')).toBeNull(); + expect(parseJailRecord('{"at":1}')).toBeNull(); + expect(parseJailRecord(JSON.stringify(shownAt))).toEqual(shownAt); + }); +}); diff --git a/frontend-v2/src/model/jail.ts b/frontend-v2/src/model/jail.ts new file mode 100644 index 00000000..65f5504b --- /dev/null +++ b/frontend-v2/src/model/jail.ts @@ -0,0 +1,194 @@ +import type { DerivedPull } from '../../../shared/model/status'; +import { isBotLogin } from '../../../shared/model/visibility'; +import type { PullData } from '../../../shared/types'; +import { rowWord } from './actions'; + +/** Jail when you have more than this many open PRs of your own. */ +export const JAIL_MAX_OPEN = 7; +/** Jail when any of your open PRs is older than this many days. */ +export const JAIL_MAX_DAYS = 14; + +/** localStorage key holding the JailRecord of the last time jail showed. */ +export const JAIL_KEY = 'pd2.jail'; +/** After jail shows, it stays away at least this long. */ +export const JAIL_COOLDOWN_MS = 4 * 60 * 60 * 1000; + +/** What jail showed you last time, so it only comes back when that got worse. */ +export interface JailRecord { + /** ms when it showed */ + at: number; + /** how many counted open PRs you had then */ + count: number; + /** repo#number of each PR past the age limit then */ + over: string[]; +} + +/** The fields the rule reads off a derived pull. */ +export interface JailPull { + data: Pick; + ageDays: number; + /** who holds a deploy block on it; a held PR is done, so it doesn't count */ + deployBlockedBy: string[]; + status: DerivedPull['status']; + /** parked (Cryogenic Storage): kept open on purpose, never "ready" */ + cryo: boolean; +} + +/** Can merge now, the quickest way out. A parked PR can be signed off and + * green, but My work files it under waiting, so jail does too. */ +export const isJailReady = (p: JailPull) => p.status === 'ready' && !p.cryo; + +export interface JailCase

    { + /** the PRs that put you there, oldest first */ + pulls: P[]; + /** one line saying why, and how to get out */ + why: string; + /** how many open PRs counted */ + count: number; + /** repo#number of each counted PR past the age limit */ + over: string[]; + /** how many PRs you still have to merge or close to be free: enough to + * get under the count limit, and every one past the age limit (closing an + * old one counts toward both) */ + toGo: number; +} + +const idOf = (p: JailPull) => `${p.data.repo}#${p.data.number}`; + +const oldestFirst =

    (a: P, b: P) => b.ageDays - a.ageDays; + +/** + * Your own open, human PRs that aren't held for a deploy, checked against the + * two limits; drafts count only when `countDrafts` is on. Null when you're + * under both. + */ +export function jailCase

    ( + pulls: P[], + me: string, + extraBots: ReadonlySet, + limits: { maxOpen: number; maxDays: number; countDrafts?: boolean } +): JailCase

    | null { + const mine = pulls + .filter( + p => + p.data.user.login === me && + p.data.state === 'open' && + (limits.countDrafts || !p.data.draft) && + !p.deployBlockedBy.length && + !isBotLogin(p.data.user.login, extraBots) + ) + .sort(oldestFirst); + const old = mine.filter(p => p.ageDays > limits.maxDays); + const counts = { + count: mine.length, + over: old.map(idOf), + toGo: Math.max(mine.length - limits.maxOpen, old.length), + }; + if (mine.length > limits.maxOpen) { + const why = countWhy( + mine.length, + limits.maxOpen, + mine.filter(isJailReady).length, + old.filter(p => !isJailReady(p)), + limits.maxDays + ); + return { pulls: mine, why, ...counts }; + } + if (old.length) { + return { pulls: old, why: ageWhy(old, limits.maxDays), ...counts }; + } + return null; +} + +/** The count line, pointing at the ready PRs first since merging one is + * the quickest way to lower the count. `stillOld` are the PRs past the age + * limit that merging the ready ones won't clear, so it never promises + * "you're out" while one is left. */ +// ponytail: when more are ready than needed, an old ready PR left unmerged +// still holds you; the line doesn't say which ready ones to pick +function countWhy( + n: number, + max: number, + ready: number, + stillOld: JailPull[], + maxDays: number +): string { + const need = n - max; + const head = `${n} open PRs. Parole at ${max}.`; + const yours = ready === 1 ? 'your ready one' : `your ${ready} ready ones`; + const out = stillOld.length ? `to get under ${max}` : 'and you’re out'; + const tail = !stillOld.length + ? '' + : stillOld.length === 1 + ? ` #${stillOld[0].data.number} is still past ${maxDays} days.` + : ` ${stillOld.length} PRs are still past ${maxDays} days.`; + if (!ready) + return `${head} Close ${need} to get ${stillOld.length ? `under ${max}` : 'out'}.${tail}`; + if (ready < need) return `${head} Merge ${yours} and close ${need - ready} more.${tail}`; + if (ready === need) return `${head} Merge ${yours} ${out}.${tail}`; + return `${head} Merge ${need} of ${yours} ${out}.${tail}`; +} + +function ageWhy(old: JailPull[], maxDays: number): string { + if (old.length > 1) { + return `${old.length} PRs open past ${maxDays} days. Merge or close them to get out.`; + } + const [p] = old; + const head = `#${p.data.number} has been open ${p.ageDays} days`; + return isJailReady(p) + ? `${head} and it’s ready. Merge it to get out.` + : `${head}. Parole at ${maxDays} days. Merge or close it to get out.`; +} + +/** + * A jail's PRs split the way My work splits them, by the same rowWord, with + * the ready ones lifted out on top: ready to merge, your move, then waiting + * on others. Each group runs oldest first; the row's word says the rest. + */ +export function jailGroups(pulls: DerivedPull[], me: string) { + const worded = pulls + .map(pull => ({ pull, word: rowWord(pull, me) })) + .sort((a, b) => b.pull.ageDays - a.pull.ageDays); + const rest = worded.filter(w => !isJailReady(w.pull)); + return { + ready: worded.filter(w => isJailReady(w.pull)), + move: rest.filter(w => w.word.kind === 'do'), + waiting: rest.filter(w => w.word.kind === 'wait'), + }; +} + +/** The record to store when `jail` shows at `now`. */ +export const jailRecord = (jail: JailCase, now: number): JailRecord => ({ + at: now, + count: jail.count, + over: jail.over, +}); + +/** A stored record, or null when it's missing or unreadable. */ +export function parseJailRecord(raw: string | null): JailRecord | null { + try { + const r = JSON.parse(raw ?? 'null') as Partial | null; + if (typeof r?.at !== 'number' || typeof r.count !== 'number' || !Array.isArray(r.over)) { + return null; + } + return { at: r.at, count: r.count, over: r.over }; + } catch { + return null; + } +} + +/** + * Whether jail should drop now: you're over a limit, and either it never + * showed, or it's been at least JAIL_COOLDOWN_MS and things got worse since + * (more open PRs, net, or a PR newly past the age limit). + */ +export function jailDue( + jail: JailCase | null, + last: JailRecord | null, + now: number +): boolean { + if (!jail) return false; + if (!last) return true; + if (now - last.at < JAIL_COOLDOWN_MS) return false; + return jail.count > last.count || jail.over.some(id => !last.over.includes(id)); +} diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 6e0366a0..b8686fbe 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -1,4 +1,5 @@ import { STARVE_DAYS } from '../../shared/model/status'; +import { JAIL_MAX_DAYS, JAIL_MAX_OPEN } from './model/jail'; import { createPersistentStore } from './storage'; /** one named review circle; DEFAULT_TEAM_NAME is what quick add-to-team @@ -114,6 +115,15 @@ export interface Settings { * or age tooltip) before it opens, so brushing the pointer across the board * doesn't flash panels open. 0 = open instantly; a click always bypasses it. */ hoverDelayMs: number; + /** PR jail: 'auto' pops it up when you're over the limits, 'manual' only + * shows the header lock (click to open), 'off' shows neither */ + jailMode: 'auto' | 'manual' | 'off'; + /** PR jail pops up when you have more than this many open PRs of your own */ + jailMaxOpen: number; + /** ...or when any of them has been open longer than this many days */ + jailMaxDays: number; + /** whether your draft PRs count toward PR jail */ + jailCountDrafts: boolean; } /** The heaviest age text tier (red), derived from ageWarnDays rather than @@ -167,9 +177,21 @@ export const DEFAULT_SETTINGS: Settings = { repoQueueCap: 15, openPrsNewTab: true, hoverDelayMs: 250, + jailMode: 'auto', + jailMaxOpen: JAIL_MAX_OPEN, + jailMaxDays: JAIL_MAX_DAYS, + jailCountDrafts: false, }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); +// jailOn (an on/off switch, live for a day) became jailMode; an Off carries over +{ + const saved = store.get() as Settings & { jailOn?: boolean }; + if (saved.jailOn !== undefined) { + const { jailOn, ...rest } = saved; + store.set({ ...rest, jailMode: jailOn ? rest.jailMode : 'off' }); + } +} /** Union of every personal roster, sorted — "your people": the set that * leads the review queues and wears the teammate corner heart. */ diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index e6bb49a5..ff41a068 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1006,3 +1006,69 @@ input[type="checkbox"] { font-size: 13px; font-weight: 600; } + +/* PR jail's snooze key (components/JailMode.tsx): a slow wiggle in the + keyhole while it waits to be held. Transform only, and still when motion + is reduced. */ +@keyframes jail-key-wiggle { + 0%, + 60%, + 100% { + rotate: 0deg; + } + 70% { + rotate: -14deg; + } + 80% { + rotate: 12deg; + } + 90% { + rotate: -6deg; + } +} +.jail-key-wiggle { + animation: jail-key-wiggle 2.8s ease-in-out infinite; +} +/* ...and the shackle opening once it has turned: up out of the body, then + over its right leg (x 30) to the other side, like a padlock swinging + open. Reduced motion lands on the open lock with no swing. */ +@keyframes jail-shackle-open { + from { + transform: translateY(0) scaleX(1); + } + 35% { + transform: translateY(-5px) scaleX(1); + } + to { + transform: translateY(-5px) scaleX(-1); + } +} +.jail-shackle-open { + transform-origin: 30px 18px; + transform: translateY(-5px) scaleX(-1); + animation: jail-shackle-open 600ms cubic-bezier(0.22, 1, 0.36, 1); +} +/* ...and a merged or closed PR's row sliding out of the list while the + countdown ticks. */ +@keyframes jail-row-leave { + to { + opacity: 0; + transform: translateX(1.5rem); + max-height: 0; + padding-block: 0; + } +} +.jail-row-leave { + max-height: 8rem; + overflow: hidden; + animation: jail-row-leave 450ms cubic-bezier(0.5, 0, 0.75, 0) forwards; +} +@media (prefers-reduced-motion: reduce) { + .jail-key-wiggle, + .jail-shackle-open { + animation: none; + } + .jail-row-leave { + animation-duration: 1ms; + } +}