;
+ 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;
+ }
+}