fix(timeline): stabilize overlay lifecycle
This commit is contained in:
@@ -438,20 +438,22 @@ async function openAllocationContextMenuAtOffset(
|
|||||||
locator: ReturnType<Page["locator"]>,
|
locator: ReturnType<Page["locator"]>,
|
||||||
xOffset: number,
|
xOffset: number,
|
||||||
) {
|
) {
|
||||||
const box = await locator.boundingBox();
|
const target = await resolveAllocationContextMenuTarget(locator);
|
||||||
expect(box).not.toBeNull();
|
const box = await readBoundingBox(target);
|
||||||
if (!box) {
|
|
||||||
throw new Error("Expected allocation segment to be visible before context click");
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" });
|
await page.mouse.click(
|
||||||
|
box.x + Math.min(Math.max(xOffset, 2), Math.max(box.width - 2, 2)),
|
||||||
|
box.y + box.height / 2,
|
||||||
|
{ button: "right" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openContextMenuAtCenter(
|
async function openContextMenuAtCenter(
|
||||||
page: Page,
|
page: Page,
|
||||||
locator: ReturnType<Page["locator"]>,
|
locator: ReturnType<Page["locator"]>,
|
||||||
) {
|
) {
|
||||||
const box = await readBoundingBox(locator);
|
const target = await resolveAllocationContextMenuTarget(locator);
|
||||||
|
const box = await readBoundingBox(target);
|
||||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +502,11 @@ async function readBoundingBox(locator: ReturnType<Page["locator"]>) {
|
|||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveAllocationContextMenuTarget(locator: ReturnType<Page["locator"]>) {
|
||||||
|
const interactionTarget = locator.locator("[data-allocation-interaction='body']").first();
|
||||||
|
return (await interactionTarget.count()) > 0 ? interactionTarget : locator;
|
||||||
|
}
|
||||||
|
|
||||||
async function listRenderedAllocationSegments(
|
async function listRenderedAllocationSegments(
|
||||||
row: ReturnType<Page["locator"]>,
|
row: ReturnType<Page["locator"]>,
|
||||||
allocationId?: string,
|
allocationId?: string,
|
||||||
@@ -588,6 +595,16 @@ async function findVisibleAllocationSegmentForResize(
|
|||||||
selector: string,
|
selector: string,
|
||||||
): Promise<VisibleAllocationSegment | null> {
|
): Promise<VisibleAllocationSegment | null> {
|
||||||
return page.locator(selector).evaluateAll((elements) => {
|
return page.locator(selector).evaluateAll((elements) => {
|
||||||
|
const scrollContainer = document.querySelector<HTMLElement>(
|
||||||
|
"div.app-surface.relative.z-0.flex-1.overflow-auto",
|
||||||
|
);
|
||||||
|
const stickyHeaderBottom = scrollContainer
|
||||||
|
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
|
||||||
|
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
|
||||||
const candidates: Array<{
|
const candidates: Array<{
|
||||||
allocationId: string;
|
allocationId: string;
|
||||||
segmentStart: string | null;
|
segmentStart: string | null;
|
||||||
@@ -608,7 +625,7 @@ async function findVisibleAllocationSegmentForResize(
|
|||||||
rect.height >= 10 &&
|
rect.height >= 10 &&
|
||||||
rect.right > 48 &&
|
rect.right > 48 &&
|
||||||
rect.left < window.innerWidth - 48 &&
|
rect.left < window.innerWidth - 48 &&
|
||||||
rect.bottom > 0 &&
|
rect.bottom > safeTop &&
|
||||||
rect.top < window.innerHeight;
|
rect.top < window.innerHeight;
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
@@ -625,7 +642,8 @@ async function findVisibleAllocationSegmentForResize(
|
|||||||
rect.width >= 36 &&
|
rect.width >= 36 &&
|
||||||
rect.width <= 220 &&
|
rect.width <= 220 &&
|
||||||
rect.left >= 48 &&
|
rect.left >= 48 &&
|
||||||
rect.right <= window.innerWidth - 48;
|
rect.right <= window.innerWidth - 48 &&
|
||||||
|
rect.top >= safeTop;
|
||||||
|
|
||||||
if (!isPreferred) {
|
if (!isPreferred) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1197,6 +1215,26 @@ test.describe("Timeline", () => {
|
|||||||
await expect(cancelButton).not.toBeVisible();
|
await expect(cancelButton).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("timeline allocation popovers close cleanly across view switches", async ({ page }) => {
|
||||||
|
const allocation = page
|
||||||
|
.locator("[data-timeline-entry-type='allocation'][data-allocation-id]")
|
||||||
|
.first();
|
||||||
|
await expect(allocation).toBeVisible();
|
||||||
|
await openContextMenuAtCenter(page, allocation);
|
||||||
|
|
||||||
|
const allocationPopover = page.getByTestId("timeline-allocation-popover");
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, {
|
||||||
|
timeout: 2_000,
|
||||||
|
});
|
||||||
|
await expect(allocationPopover).toBeVisible();
|
||||||
|
|
||||||
|
await switchToProjectView(page);
|
||||||
|
await expect(allocationPopover).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("project bars stay attached to the pointer during fast drag", async ({ page }) => {
|
test("project bars stay attached to the pointer during fast drag", async ({ page }) => {
|
||||||
await switchToProjectView(page);
|
await switchToProjectView(page);
|
||||||
await expect(page.locator("[data-timeline-entry-type='project-bar']").first()).toBeVisible();
|
await expect(page.locator("[data-timeline-entry-type='project-bar']").first()).toBeVisible();
|
||||||
@@ -1351,7 +1389,7 @@ test.describe("Timeline", () => {
|
|||||||
expect(rightResizeAssignments).toHaveLength(1);
|
expect(rightResizeAssignments).toHaveLength(1);
|
||||||
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||||
expect(rightResizeAssignments[0]?.startDate).toBe("2026-04-06");
|
expect(rightResizeAssignments[0]?.startDate).toBe("2026-04-06");
|
||||||
expect(rightResizeAssignments[0]?.endDate > "2026-04-17").toBe(true);
|
expect(rightResizeAssignments[0]!.endDate > "2026-04-17").toBe(true);
|
||||||
|
|
||||||
const resizeStart = await measureAllocationResizeStartGap(page, projectAllocationSelector);
|
const resizeStart = await measureAllocationResizeStartGap(page, projectAllocationSelector);
|
||||||
expect(resizeStart.widthGain).toBeGreaterThan(48);
|
expect(resizeStart.widthGain).toBeGreaterThan(48);
|
||||||
@@ -1374,7 +1412,7 @@ test.describe("Timeline", () => {
|
|||||||
.not.toBe("2026-04-06");
|
.not.toBe("2026-04-06");
|
||||||
expect(leftResizeAssignments).toHaveLength(1);
|
expect(leftResizeAssignments).toHaveLength(1);
|
||||||
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||||
expect(leftResizeAssignments[0]?.startDate < "2026-04-06").toBe(true);
|
expect(leftResizeAssignments[0]!.startDate < "2026-04-06").toBe(true);
|
||||||
expect(leftResizeAssignments[0]?.endDate).toBe(rightResizeAssignments[0]?.endDate);
|
expect(leftResizeAssignments[0]?.endDate).toBe(rightResizeAssignments[0]?.endDate);
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
@@ -1429,7 +1467,7 @@ test.describe("Timeline", () => {
|
|||||||
expect(rightResizeDemands).toHaveLength(1);
|
expect(rightResizeDemands).toHaveLength(1);
|
||||||
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
|
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||||
expect(rightResizeDemands[0]?.startDate).toBe("2026-04-07");
|
expect(rightResizeDemands[0]?.startDate).toBe("2026-04-07");
|
||||||
expect(rightResizeDemands[0]?.endDate > "2026-04-16").toBe(true);
|
expect(rightResizeDemands[0]!.endDate > "2026-04-16").toBe(true);
|
||||||
expect(rightResizeDemands[0]?.headcount).toBe(2);
|
expect(rightResizeDemands[0]?.headcount).toBe(2);
|
||||||
expect(rightResizeDemands[0]?.status).toBe("PROPOSED");
|
expect(rightResizeDemands[0]?.status).toBe("PROPOSED");
|
||||||
|
|
||||||
@@ -1461,7 +1499,7 @@ test.describe("Timeline", () => {
|
|||||||
.not.toBe("2026-04-07");
|
.not.toBe("2026-04-07");
|
||||||
expect(leftResizeDemands).toHaveLength(1);
|
expect(leftResizeDemands).toHaveLength(1);
|
||||||
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
|
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||||
expect(leftResizeDemands[0]?.startDate < "2026-04-07").toBe(true);
|
expect(leftResizeDemands[0]!.startDate < "2026-04-07").toBe(true);
|
||||||
expect(leftResizeDemands[0]?.endDate).toBe(rightResizeDemands[0]?.endDate);
|
expect(leftResizeDemands[0]?.endDate).toBe(rightResizeDemands[0]?.endDate);
|
||||||
expect(leftResizeDemands[0]?.headcount).toBe(2);
|
expect(leftResizeDemands[0]?.headcount).toBe(2);
|
||||||
expect(leftResizeDemands[0]?.status).toBe("PROPOSED");
|
expect(leftResizeDemands[0]?.status).toBe("PROPOSED");
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ function TimelineViewContent({
|
|||||||
anchorEl: HTMLElement;
|
anchorEl: HTMLElement;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const previousViewModeRef = useRef(viewMode);
|
||||||
|
|
||||||
const invalidateTimelineInner = useInvalidateTimeline();
|
const invalidateTimelineInner = useInvalidateTimeline();
|
||||||
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
||||||
@@ -386,6 +387,26 @@ function TimelineViewContent({
|
|||||||
setResourceHover(null);
|
setResourceHover(null);
|
||||||
}, [hasActivePointerOverlay]);
|
}, [hasActivePointerOverlay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousViewModeRef.current === viewMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousViewModeRef.current = viewMode;
|
||||||
|
setPopover(null);
|
||||||
|
setDemandPopover(null);
|
||||||
|
setNewAllocPopover(null);
|
||||||
|
setResourceHover(null);
|
||||||
|
}, [viewMode, setNewAllocPopover]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialLoading) return;
|
||||||
|
setPopover(null);
|
||||||
|
setDemandPopover(null);
|
||||||
|
setNewAllocPopover(null);
|
||||||
|
setResourceHover(null);
|
||||||
|
}, [isInitialLoading, setNewAllocPopover]);
|
||||||
|
|
||||||
// ─── Keep selection overlay visible while popover is open ───────────────────
|
// ─── Keep selection overlay visible while popover is open ───────────────────
|
||||||
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
|
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
|
||||||
? rangeState
|
? rangeState
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export function useViewportPopover({
|
|||||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
|
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const closeOnViewportChange = anchor.kind === "point";
|
||||||
|
|
||||||
function cancelScheduledFrame() {
|
function cancelScheduledFrame() {
|
||||||
if (frameRef.current === null) return;
|
if (frameRef.current === null) return;
|
||||||
cancelAnimationFrame(frameRef.current);
|
cancelAnimationFrame(frameRef.current);
|
||||||
@@ -181,9 +183,19 @@ export function useViewportPopover({
|
|||||||
updateOrClose();
|
updateOrClose();
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
if (closeOnViewportChange) {
|
||||||
|
cancelScheduledFrame();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
scheduleUpdate("scroll");
|
scheduleUpdate("scroll");
|
||||||
};
|
};
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
|
if (closeOnViewportChange) {
|
||||||
|
cancelScheduledFrame();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
scheduleUpdate("resize");
|
scheduleUpdate("resize");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Showcase Execution Batches
|
||||||
|
|
||||||
|
**Date:** 2026-04-01
|
||||||
|
**Purpose:** Canonical execution list for the remaining work to turn CapaKraken into a clean, deterministic, reviewable reference project for disciplined AI-assisted engineering.
|
||||||
|
|
||||||
|
## How To Use This List
|
||||||
|
|
||||||
|
- Work top to bottom unless a hard blocker forces resequencing.
|
||||||
|
- Keep slices small enough to verify and commit cleanly.
|
||||||
|
- Every touched slice must add at least one non-happy-path regression.
|
||||||
|
- Do not broaden scope while the worktree is dirty.
|
||||||
|
- A batch is done only when code, tests, and documentation for that slice are aligned.
|
||||||
|
|
||||||
|
## Batch Status Legend
|
||||||
|
|
||||||
|
- `todo`: not started
|
||||||
|
- `active`: current execution lane
|
||||||
|
- `blocked`: waiting on a hard external dependency or product decision
|
||||||
|
- `done`: implemented, verified, and committed
|
||||||
|
|
||||||
|
## Batch 1: Timeline Stability And Interaction Discipline
|
||||||
|
|
||||||
|
Status: `active`
|
||||||
|
Why first:
|
||||||
|
the timeline is the highest-risk UX surface and still not boringly reliable enough for the showcase goal.
|
||||||
|
|
||||||
|
Progress note:
|
||||||
|
- 2026-04-01: overlay cleanup on timeline `viewMode` changes and initial-loading transitions landed with targeted e2e regression coverage for allocation popovers across view switches.
|
||||||
|
- 2026-04-01: viewport-change behavior was tightened so point-anchored timeline popovers close on scroll/resize, while element-anchored hover cards remain repositionable; the viewport regression now passes with a non-happy-path e2e.
|
||||||
|
|
||||||
|
Slices:
|
||||||
|
|
||||||
|
- `todo` stabilize popover and hover behavior across scroll, resize, reload, and view switches
|
||||||
|
- `todo` verify drag, resize, and selection interactions do not compete under rapid input changes
|
||||||
|
- `todo` verify SSE first-load, reconnect, and live-update behavior under churn
|
||||||
|
- `todo` close explicit edge/failure test gaps in timeline e2e and targeted component tests
|
||||||
|
- `todo` continue shrinking [TimelineView.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/timeline/TimelineView.tsx) into narrower ownership units without changing behavior
|
||||||
|
|
||||||
|
Done criteria:
|
||||||
|
|
||||||
|
- timeline overlays stay anchored under scroll and resize
|
||||||
|
- timeline view switching and first-load state are deterministic
|
||||||
|
- drag and selection paths remain stable under quick repeated interactions
|
||||||
|
- SSE reconnects do not leave stale or empty timeline state
|
||||||
|
- touched timeline slices have non-happy-path regression coverage
|
||||||
|
|
||||||
|
## Batch 2: Holiday/Vacation Parity And Explainability
|
||||||
|
|
||||||
|
Status: `todo`
|
||||||
|
Why next:
|
||||||
|
it is a cross-cutting correctness area that benefits from the same deterministic test discipline without expanding architecture too early.
|
||||||
|
|
||||||
|
Slices:
|
||||||
|
|
||||||
|
- `todo` identify remaining parity gaps between API, UI, assistant, widgets, and exports
|
||||||
|
- `todo` unify holiday- and vacation-aware forecast derivations where outputs still diverge
|
||||||
|
- `todo` standardize compact vs detailed explainability surfaces
|
||||||
|
- `todo` add regression coverage for mismatch and boundary scenarios, not only nominal calculations
|
||||||
|
|
||||||
|
Done criteria:
|
||||||
|
|
||||||
|
- the same holiday and vacation basis is visible across all user-facing surfaces
|
||||||
|
- explainability is consistent and concise by default
|
||||||
|
- parity is backed by targeted cross-surface tests
|
||||||
|
|
||||||
|
## Batch 3: Resource Read Decomposition And Smaller Ownership Surfaces
|
||||||
|
|
||||||
|
Status: `todo`
|
||||||
|
Why next:
|
||||||
|
this is the lowest-conflict structural cleanup explicitly called out as ready in the API backlog.
|
||||||
|
|
||||||
|
Slices:
|
||||||
|
|
||||||
|
- `todo` split resource read orchestration out of `resource-read-shared.ts` into focused read helpers without changing API contracts
|
||||||
|
- `todo` keep router files thin and procedure-support focused
|
||||||
|
- `todo` continue breaking down large frontend ownership surfaces, starting with [ProjectWizard.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/projects/ProjectWizard.tsx)
|
||||||
|
- `todo` add focused tests around extracted helpers before broadening further
|
||||||
|
|
||||||
|
Done criteria:
|
||||||
|
|
||||||
|
- `resource-read-shared.ts` no longer acts as a monolithic read hotspot
|
||||||
|
- extracted helpers have narrow responsibilities and direct tests
|
||||||
|
- large frontend modules have smaller reviewable ownership seams
|
||||||
|
|
||||||
|
## Batch 4: Notification, Task, And Broadcast Reliability Residuals
|
||||||
|
|
||||||
|
Status: `todo`
|
||||||
|
Why now:
|
||||||
|
this is important operational hardening, but below timeline and read-surface cleanup in immediate risk.
|
||||||
|
|
||||||
|
Slices:
|
||||||
|
|
||||||
|
- `todo` identify remaining missing-reference and retry edge cases
|
||||||
|
- `todo` stabilize error translation so failures stay domain-shaped and predictable
|
||||||
|
- `todo` narrow orchestration helpers where the current flow still mixes concerns
|
||||||
|
- `todo` add regression coverage for retry, missing-target, and partial-failure scenarios
|
||||||
|
|
||||||
|
Done criteria:
|
||||||
|
|
||||||
|
- reminder, task, and broadcast flows fail cleanly under race and retry conditions
|
||||||
|
- errors stay stable for callers
|
||||||
|
- touched orchestration surfaces are smaller and easier to review
|
||||||
|
|
||||||
|
## Batch 5: CI Guardrails And Measurable Quality Gates
|
||||||
|
|
||||||
|
Status: `todo`
|
||||||
|
Why now:
|
||||||
|
the goal is a reference project where quality is enforced by CI, not by memory or chat history.
|
||||||
|
|
||||||
|
Slices:
|
||||||
|
|
||||||
|
- `todo` add file-size or ownership-surface checks for the next critical hotspots
|
||||||
|
- `todo` strengthen auth and permission regression coverage around sensitive routers
|
||||||
|
- `todo` add package-level quality gates beyond generic pass/fail test runs
|
||||||
|
- `todo` add bundle or route-size monitoring where growth risk is already known
|
||||||
|
|
||||||
|
Done criteria:
|
||||||
|
|
||||||
|
- CI fails on structural regressions, not only syntax and test failures
|
||||||
|
- sensitive permission surfaces have explicit regression protection
|
||||||
|
- critical source hotspots are under measurable guardrails
|
||||||
|
|
||||||
|
## Batch 6: Operational Standardization
|
||||||
|
|
||||||
|
Status: `todo`
|
||||||
|
Why now:
|
||||||
|
one production path and one rollback path are core to the showcase goal.
|
||||||
|
|
||||||
|
Slices:
|
||||||
|
|
||||||
|
- `todo` make Redis-backed rate limiting the intentional deployed default
|
||||||
|
- `todo` document one deploy path and one rollback path with verification steps
|
||||||
|
- `todo` finish runtime-secret and environment-backed configuration discipline
|
||||||
|
- `todo` verify the documented path against the current CI/CD and deploy docs
|
||||||
|
|
||||||
|
Done criteria:
|
||||||
|
|
||||||
|
- production deployment and rollback are documented as single canonical flows
|
||||||
|
- runtime config sources are explicit and consistent
|
||||||
|
- operational defaults match the documented architecture
|
||||||
|
|
||||||
|
## Batch 7: Reference Project Artifacts
|
||||||
|
|
||||||
|
Status: `todo`
|
||||||
|
Why last:
|
||||||
|
these artifacts should capture the standards proven by the earlier technical batches.
|
||||||
|
|
||||||
|
Slices:
|
||||||
|
|
||||||
|
- `todo` add `docs/engineering-doctrine.md`
|
||||||
|
- `todo` add `docs/ai-collaboration-standards.md`
|
||||||
|
- `todo` document one exemplary frontend slice and one exemplary backend slice end to end
|
||||||
|
- `todo` align those docs with the actual CI, testing, and ownership guardrails in the repo
|
||||||
|
|
||||||
|
Done criteria:
|
||||||
|
|
||||||
|
- the repository explains not only what was built, but how quality is maintained
|
||||||
|
- AI-assisted collaboration standards are explicit, reviewable, and enforceable
|
||||||
|
- at least two reference slices demonstrate the intended discipline end to end
|
||||||
|
|
||||||
|
## Immediate Execution Order
|
||||||
|
|
||||||
|
1. Batch 1: timeline stability and test closure
|
||||||
|
2. Batch 2: holiday/vacation parity
|
||||||
|
3. Batch 3: resource read decomposition
|
||||||
|
4. Batch 5: CI guardrails
|
||||||
|
5. Batch 4: notification/task/broadcast residuals
|
||||||
|
6. Batch 6: operational standardization
|
||||||
|
7. Batch 7: reference project artifacts
|
||||||
|
|
||||||
|
## Working Notes
|
||||||
|
|
||||||
|
- The demand/assignment migration itself is treated as complete and is not the active blocker anymore.
|
||||||
|
- The current north star is showcase quality, deterministic behavior, and smaller ownership surfaces.
|
||||||
|
- If a new bug appears in a P0/P1 UX surface, fix it before resuming lower-priority batches.
|
||||||
Reference in New Issue
Block a user