;
+ 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 to the finish line. A parked PR can be
+ * signed off and green, but My work files it under waiting, so this does too. */
+export const isFinishReady = (p: FinishPull) => p.status === 'ready' && !p.cryo;
+
+export interface FinishLineCase {
+ /** the PRs that put you there, oldest first */
+ pulls: P[];
+ /** how many of `pulls` can merge now */
+ ready: number;
+ /** 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 finish: 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: FinishPull) => `${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 finishLineCase
(
+ pulls: P[],
+ me: string,
+ extraBots: ReadonlySet,
+ limits: { maxOpen: number; maxDays: number; countDrafts?: boolean }
+): FinishLineCase | 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 && !old.length) return null;
+ const listed = mine.length > limits.maxOpen ? mine : old;
+ const ready = listed.filter(isFinishReady).length;
+ return {
+ pulls: listed,
+ ready,
+ ...counts,
+ };
+}
+
+/**
+ * The listed 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 finishGroups(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 => !isFinishReady(w.pull));
+ return {
+ ready: worded.filter(w => isFinishReady(w.pull)),
+ move: rest.filter(w => w.word.kind === 'do'),
+ waiting: rest.filter(w => w.word.kind === 'wait'),
+ };
+}
+
+/** The record to store when `found` shows at `now`. */
+export const finishRecord = (found: FinishLineCase, now: number): FinishRecord => ({
+ at: now,
+ count: found.count,
+ over: found.over,
+});
+
+/** A stored record, or null when it's missing or unreadable. */
+export function parseFinishRecord(raw: string | null): FinishRecord | 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 it should pop up now: you're over a limit, and either it never
+ * showed, or it's been at least FINISH_COOLDOWN_MS and things got worse since
+ * (more open PRs, net, or a PR newly past the age limit).
+ */
+export function finishDue(
+ found: FinishLineCase | null,
+ last: FinishRecord | null,
+ now: number
+): boolean {
+ if (!found) return false;
+ if (!last) return true;
+ if (now - last.at < FINISH_COOLDOWN_MS) return false;
+ return found.count > last.count || found.over.some(id => !last.over.includes(id));
+}
+
+/** One number at the top of the list you can act on, and which PRs it
+ * points at, so clicking it can open their group. */
+export interface FinishStat {
+ key: 'open' | 'old' | 'ready';
+ text: string;
+ /** the PRs this number counts */
+ counts: (p: FinishPull) => boolean;
+}
+
+/** "1 PR", "2 PRs" */
+export const prCount = (n: number) => (n === 1 ? '1 PR' : `${n} PRs`);
+
+/**
+ * The numbers you can act on, each only when above zero: how many open
+ * against the aim, how many past the age limit, how many can merge now.
+ * Waiting on others is left out: there's nothing to do about it here.
+ */
+export function finishStats(
+ found: Pick, 'count' | 'over' | 'ready'>,
+ limits: { maxOpen: number; maxDays: number }
+): FinishStat[] {
+ const stats: FinishStat[] = [];
+ if (found.count > limits.maxOpen)
+ stats.push({
+ key: 'open',
+ text: `${found.count} open PRs, aim for ${limits.maxOpen}`,
+ counts: () => true,
+ });
+ if (found.over.length)
+ stats.push({
+ key: 'old',
+ text: `${prCount(found.over.length)} older than ${limits.maxDays} days`,
+ counts: p => p.ageDays > limits.maxDays,
+ });
+ if (found.ready)
+ stats.push({
+ key: 'ready',
+ text: `${prCount(found.ready)} ready to merge`,
+ counts: isFinishReady,
+ });
+ return stats;
+}
diff --git a/frontend-v2/src/model/jail.test.ts b/frontend-v2/src/model/jail.test.ts
deleted file mode 100644
index 0069a243..00000000
--- a/frontend-v2/src/model/jail.test.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-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
deleted file mode 100644
index 65f5504b..00000000
--- a/frontend-v2/src/model/jail.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-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.test.ts b/frontend-v2/src/settings.test.ts
new file mode 100644
index 00000000..5f865ff5
--- /dev/null
+++ b/frontend-v2/src/settings.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_SETTINGS } from './settings';
+
+describe('Finish line is opt-in', () => {
+ it('shows only the header count by default, and closes without a hold', () => {
+ expect(DEFAULT_SETTINGS.finishLineMode).toBe('manual');
+ expect(DEFAULT_SETTINGS.finishLineHoldToClose).toBe(false);
+ });
+});
diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts
index 0c6feae6..a3fc0a48 100644
--- a/frontend-v2/src/settings.ts
+++ b/frontend-v2/src/settings.ts
@@ -1,5 +1,5 @@
import { STARVE_DAYS } from '../../shared/model/status';
-import { JAIL_MAX_DAYS, JAIL_MAX_OPEN } from './model/jail';
+import { FINISH_MAX_DAYS, FINISH_MAX_OPEN } from './model/finishLine';
import { createPersistentStore } from './storage';
/** one named review circle; DEFAULT_TEAM_NAME is what quick add-to-team
@@ -111,15 +111,19 @@ 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;
+ /** Finish line: 'auto' pops it up
+ * when you're over the limits, 'manual' only shows the header count
+ * (click to open), 'off' shows neither. Opt-in: 'manual' by default. */
+ finishLineMode: 'auto' | 'manual' | 'off';
+ /** Finish line counts you over when you have more than this many open PRs */
+ finishLineMaxOpen: 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;
+ finishLineMaxDays: number;
+ /** whether your draft PRs count toward Finish line */
+ finishLineCountDrafts: boolean;
+ /** Finish line closes only by holding a button down; Esc and the close
+ * button do nothing. Off by default, for people who want the friction. */
+ finishLineHoldToClose: boolean;
}
/** The heaviest age text tier (red), derived from ageWarnDays rather than
@@ -172,21 +176,14 @@ export const DEFAULT_SETTINGS: Settings = {
repoQueueCap: 15,
openPrsNewTab: true,
hoverDelayMs: 250,
- jailMode: 'auto',
- jailMaxOpen: JAIL_MAX_OPEN,
- jailMaxDays: JAIL_MAX_DAYS,
- jailCountDrafts: false,
+ finishLineMode: 'manual',
+ finishLineMaxOpen: FINISH_MAX_OPEN,
+ finishLineMaxDays: FINISH_MAX_DAYS,
+ finishLineCountDrafts: false,
+ finishLineHoldToClose: 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 ff41a068..a1d15432 100644
--- a/frontend-v2/src/styles.css
+++ b/frontend-v2/src/styles.css
@@ -1007,10 +1007,10 @@ input[type="checkbox"] {
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 {
+/* Finish line's hold button (components/FinishLine.tsx): the flag gives a
+ slow wiggle while it waits to be held. Transform only, and still when
+ motion is reduced. */
+@keyframes finish-flag-wiggle {
0%,
60%,
100% {
@@ -1026,31 +1026,12 @@ input[type="checkbox"] {
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);
+.finish-flag-wiggle {
+ animation: finish-flag-wiggle 2.8s ease-in-out infinite;
}
/* ...and a merged or closed PR's row sliding out of the list while the
countdown ticks. */
-@keyframes jail-row-leave {
+@keyframes finish-row-leave {
to {
opacity: 0;
transform: translateX(1.5rem);
@@ -1058,17 +1039,16 @@ input[type="checkbox"] {
padding-block: 0;
}
}
-.jail-row-leave {
+.finish-row-leave {
max-height: 8rem;
overflow: hidden;
- animation: jail-row-leave 450ms cubic-bezier(0.5, 0, 0.75, 0) forwards;
+ animation: finish-row-leave 450ms cubic-bezier(0.5, 0, 0.75, 0) forwards;
}
@media (prefers-reduced-motion: reduce) {
- .jail-key-wiggle,
- .jail-shackle-open {
+ .finish-flag-wiggle {
animation: none;
}
- .jail-row-leave {
+ .finish-row-leave {
animation-duration: 1ms;
}
}