-

+
+

-
-
- @for (b of visibleBlocks(); track b.id) {
-
- }
+
+
+ @for (sq of squares(); track sq.key; let i = $index) {
+
+ }
+
-
+ @if (hiddenBlockCount() > 0) {
+
+ {{ hiddenBlockCount() }} more
+ }
@if (editEntry(); as entry) {
-
+
}
`,
@@ -100,7 +233,9 @@ interface StyledBlock extends Block {
@import '../../../library/main';
:host {
+ display: block;
cursor: pointer;
+ min-height: 0;
&.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
@@ -135,12 +270,12 @@ interface StyledBlock extends Block {
transform: scale(0.75);
position: relative;
- :before {
+ &::before {
opacity: 0.5 !important;
}
}
- input {
+ .tower-header {
display: none;
}
}
@@ -149,8 +284,10 @@ interface StyledBlock extends Block {
display: flex;
flex-direction: column;
align-items: center;
+ position: relative;
max-width: 100%;
height: 100%;
+ min-height: 0;
@include inner-spacing(var(--small-padding));
@@ -158,7 +295,15 @@ interface StyledBlock extends Block {
display: flex;
flex-direction: column;
flex: 1 1 auto;
+ margin-bottom: 0;
+ min-height: 0;
position: relative;
+ box-sizing: border-box;
+ container-type: inline-size;
+ --block-stack-height: 0px;
+ --add-block-size: 48px;
+ --add-block-clearance: var(--medium-padding);
+ --add-block-center-offset: 0px;
@include card();
overflow: hidden;
@@ -166,9 +311,16 @@ interface StyledBlock extends Block {
@include inner-spacing(var(--medium-padding));
+ @media (max-width: $mobile-width) {
+ @include inner-spacing(var(--small-padding));
+ padding: 0;
+ --add-block-size: 32px;
+ --add-block-clearance: var(--small-padding);
+ }
+
width: 100%;
- :before {
+ &::before {
content: '';
pointer-events: none;
position: absolute;
@@ -184,108 +336,206 @@ interface StyledBlock extends Block {
}
lt-tasks {
- flex: 0 0 auto;
+ flex: 0 1 auto;
min-height: 56px;
- max-height: 30vh;
- overflow: hidden;
+ max-height: min(30vh, 45%);
+ overflow: auto;
display: block;
width: 100%;
+ @media (max-width: $mobile-width) {
+ min-height: 44px;
+ max-height: min(25vh, 45%);
+ }
+
.container {
max-height: 100%;
overflow-y: auto;
}
}
- img.add-block {
- flex: 0 0 auto;
- align-self: center;
- margin: var(--medium-padding) 0;
- }
-
- img {
+ .stack-zone {
position: relative;
- z-index: 2;
- height: 48px;
+ flex: 1 1 auto;
+ min-height: 0;
+ width: 100%;
- @media (max-width: $mobile-width) {
- height: 32px;
+ img {
+ position: relative;
+ z-index: 2;
+ height: 48px;
+
+ @media (max-width: $mobile-width) {
+ height: 32px;
+ }
+
+ opacity: 0.33;
+ transition: opacity $long-animation-time;
+ cursor: pointer;
+
+ &:hover {
+ opacity: 1;
+ }
}
- opacity: 0.33;
- transition: opacity $long-animation-time;
- cursor: pointer;
-
- &:hover {
- opacity: 1;
- }
- }
-
- .block-container-container {
- position: relative;
- flex: 1;
-
- .block-container {
- display: flex;
- flex-flow: row wrap;
- justify-content: flex-start;
- align-content: flex-start;
- align-items: flex-end;
+ img.add-block {
position: absolute;
- bottom: 0;
- width: 100%;
- transform: scaleY(-1);
+ z-index: 3;
+ left: 50%;
+ top: max(
+ 0px,
+ min(
+ calc(50% - var(--add-block-size) / 2 - var(--add-block-center-offset)),
+ calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size))
+ )
+ );
+ transform: translateX(-50%);
+ }
- /* Default resting position for all blocks before JS sets them */
- * {
- transform: translateY(500%);
- }
+ .block-container-container {
+ position: absolute;
+ inset: 0;
- .descend {
- transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
- opacity 500ms cubic-bezier(0.5, 0, 1, 0);
- }
+ .block-container {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: flex-start;
+ align-content: flex-start;
+ align-items: flex-end;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ transform: scaleY(-1);
- .ascend {
- transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
- opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
+ /* Default resting position for all blocks before JS sets them */
+ * {
+ transform: translateY(500%);
+ }
+
+ .descend {
+ transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
+ opacity 500ms cubic-bezier(0.5, 0, 1, 0);
+ }
+
+ .ascend {
+ transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
+ opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
+ }
}
}
}
}
- input[type='text'] {
- font-size: var(--small-font-size);
+ .more-blocks {
+ @include small-text();
+ position: absolute;
+ top: calc(100% + var(--small-padding));
+ left: 0;
+ right: 0;
+ margin: 0;
+ line-height: 1;
+ color: rgba($text-color, 0.72);
text-align: center;
+ pointer-events: none;
+ user-select: none;
+ }
- @media (min-width: $mobile-width) {
- width: 50%;
+ .tower-header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+
+ input[type='text'] {
+ box-sizing: border-box;
+ min-width: 0;
+ font-size: var(--small-font-size);
+ text-align: center;
+
+ /* Truncate long titles with an ellipsis instead of wrapping. */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ /* Reserve a symmetric gutter on each side. The right one keeps the
+ title and its focus underline clear of the absolutely-positioned
+ pen (so the underline stops before it); the equal left one keeps
+ the centered title optically centered. (pen 22px + 4px gap.) */
+ width: calc(100% - 52px);
+
+ @media (max-width: $mobile-width) {
+ width: calc(100% - 60px);
+ }
+ }
+
+ .edit-tower {
+ all: unset;
+ position: absolute;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ box-sizing: border-box;
+ width: 22px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ opacity: 0.35;
+ transition: opacity $short-animation-time, box-shadow $long-animation-time, background-color $short-animation-time;
+
+ /* Suppress the global button's animated hover underline
+ (button::after), which the 'all: unset' reset doesn't reach. */
+ &:after {
+ content: none;
+ }
+
+ img {
+ width: 13px;
+ height: 13px;
+ opacity: 1;
+ }
+
+ &:hover,
+ &:focus-visible {
+ opacity: 1;
+ background-color: rgba($light-color, 0.86);
+ box-shadow: $shadow-border;
+ }
+
+ @media (max-width: $mobile-width) {
+ width: 26px;
+ height: 26px;
+ opacity: 0.55;
+ }
}
}
}
}
`,
})
-export class TowerComponent {
+export class TowerComponent implements AfterViewInit, OnDestroy {
// ── Inputs ─────────────────────────────────────────────────────────────────
readonly tower = input.required
();
- /** Set by page component when this tower is being dragged over trash. */
- readonly trashHighlight = input(false);
/** Optional date range filter — when set, blocks with `created_at`
* outside [from, to] are hidden from the falling stack. */
readonly dateRange = input<{ from: number; to: number } | null>(null);
/** When true, the tasks accordion starts expanded on load. */
readonly keepTasksOpen = input(false);
+ /** When true, completed blocks descend on this tower's first measured render. */
+ readonly animateInitialStack = input(false);
// ── Outputs ────────────────────────────────────────────────────────────────
readonly updateTower = output();
readonly deleteTowerRequest = output();
/** Emitted when a new block is created from the carousel's "Create now" card. */
- readonly addBlock = output<{ tag: string; description: string; is_done: boolean }>();
+ readonly addBlock = output<{ tag: string; description: string; is_done: boolean; difficulty: number }>();
/** Emitted when an existing block is patched from the carousel. */
readonly saveBlock = output<{
blockId: string;
- result: { tag: string; description: string; is_done: boolean };
+ result: { tag: string; description: string; is_done: boolean; difficulty: number };
}>();
readonly deleteBlock = output();
@@ -294,10 +544,19 @@ export class TowerComponent {
* which list of blocks to show and which one to focus initially. */
readonly editEntry = signal(null);
readonly showSettings = signal(false);
+ readonly hiddenBlockCount = signal(0);
+ readonly hoveredBlockId = signal(null);
+
+ private readonly stackZone = viewChild>('stackZone');
+ private readonly towerRoot = viewChild>('towerRoot');
+ private readonly maxVisibleBlocks = signal(null);
+ private resizeObserver: ResizeObserver | null = null;
+ private destroyed = false;
+ private readonly animationFrames = new Set();
// ── Derived ────────────────────────────────────────────────────────────────
/** Pending (not-done) blocks — fed to the tasks accordion. */
- readonly pending = computed(() => this.tower().blocks.filter(b => !b.is_done));
+ readonly pending = computed(() => this.tower().blocks.filter((b) => !b.is_done));
/** CSS color string for the tower name input. */
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
@@ -307,7 +566,14 @@ export class TowerComponent {
const entry = this.editEntry();
if (!entry) return [];
const isDone = entry.filter === 'done';
- return this.tower().blocks.filter(b => b.is_done === isDone);
+ return this.tower().blocks.filter((b) => b.is_done === isDone);
+ });
+
+ readonly editViewTitle = computed(() => {
+ const entry = this.editEntry();
+ if (!entry) return '';
+ const prefix = entry.filter === 'done' ? 'Completed tasks' : 'Tasks';
+ return `${prefix} of ${this.tower().name}`;
});
/** Unique tags from existing blocks of this tower. */
@@ -326,33 +592,130 @@ export class TowerComponent {
private readonly _visibleBlocks = signal([]);
readonly visibleBlocks = this._visibleBlocks.asReadonly();
+ /** Flat list of squares to render: each visible block expands into
+ * `difficulty` adjacent squares that wrap (via the flex container) into
+ * the row above. All squares of a block share its animation state. */
+ readonly squares = computed(() => {
+ const out: RenderSquare[] = [];
+ for (const b of this.visibleBlocks()) {
+ const n = squareCount(b);
+ for (let k = 0; k < n; k++) {
+ out.push({ key: `${b.id}#${k}`, block: b });
+ }
+ }
+ return out;
+ });
+
+ readonly blockStackHeight = computed(() => {
+ const rows = Math.ceil(this.squares().length / BLOCKS_PER_ROW);
+ const cqw = rows * (100 / BLOCKS_PER_ROW);
+ return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
+ });
+
private prevDoneIds: string[] = [];
private isFirstRun = true;
constructor() {
effect(() => {
const range = this.dateRange();
- // Always keep ALL done blocks in the visible list. The date filter
- // only flips per-block `_anim` so the 1.5s ascend/descend transition
- // can animate them in and out of the falling stack.
+ const maxVisibleBlocks = this.maxVisibleBlocks();
+ const animateInitialStack = this.animateInitialStack();
+ // Reconcile all done blocks, then cap the rendered stack to the rows
+ // that fit below the tasks and add button.
const allDone = this.tower().blocks.filter((b) => b.is_done);
- untracked(() => this.reconcile(allDone, range));
+ untracked(() => this.reconcile(allDone, range, maxVisibleBlocks, animateInitialStack));
});
}
- private reconcile(allDone: Block[], range: { from: number; to: number } | null): void {
+ ngAfterViewInit(): void {
+ const stackZone = this.stackZone()?.nativeElement;
+ if (!stackZone) return;
+
+ this.measureBlockCapacity();
+
+ if (typeof ResizeObserver !== 'undefined') {
+ this.resizeObserver = new ResizeObserver(() => this.measureBlockCapacity());
+ this.resizeObserver.observe(stackZone);
+ }
+
+ if (typeof requestAnimationFrame === 'function') {
+ requestAnimationFrame(() => this.measureBlockCapacity());
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.destroyed = true;
+ for (const id of this.animationFrames) cancelAnimationFrame(id);
+ this.animationFrames.clear();
+ this.resizeObserver?.disconnect();
+ }
+
+ private measureBlockCapacity(): void {
+ const stackZone = this.stackZone()?.nativeElement;
+ if (!stackZone) return;
+
+ const width = stackZone.clientWidth;
+ const height = stackZone.clientHeight;
+ if (width <= 0 || height <= 0) {
+ this.maxVisibleBlocks.set(0);
+ return;
+ }
+
+ const styles = getComputedStyle(stackZone);
+ const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
+ this.measureAddBlockCenterOffset(stackZone);
+ const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
+ const clearance = this.parseCssPixels(
+ styles.getPropertyValue('--add-block-clearance'),
+ fallbackClearance,
+ );
+
+ const rowHeight = width / BLOCKS_PER_ROW;
+ const availableHeight = Math.max(0, height - addBlockSize - 2 * clearance);
+ const rows = Math.floor((availableHeight + 0.5) / rowHeight);
+ this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
+ }
+
+ private measureAddBlockCenterOffset(stackZone: HTMLElement): void {
+ const towerRoot = this.towerRoot()?.nativeElement;
+ if (!towerRoot) return;
+
+ const stackRect = stackZone.getBoundingClientRect();
+ const towerRect = towerRoot.getBoundingClientRect();
+ const stackCenter = stackRect.top + stackRect.height / 2;
+ const towerCenter = towerRect.top + towerRect.height / 2;
+ const offset = Math.max(0, stackCenter - towerCenter);
+ stackZone.style.setProperty('--add-block-center-offset', `${offset}px`);
+ }
+
+ private parseCssPixels(value: string, fallback: number): number {
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+ }
+
+ private reconcile(
+ allDone: Block[],
+ range: { from: number; to: number } | null,
+ maxVisibleBlocks: number | null,
+ animateInitialStack: boolean,
+ ): void {
+ if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
+ this._visibleBlocks.set([]);
+ this.hiddenBlockCount.set(0);
+ this.prevDoneIds = allDone.map((b) => b.id);
+ this.isFirstRun = false;
+ return;
+ }
+
const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds;
const prevSet = new Set(prev);
- const newIds = ids.filter(id => !prevSet.has(id));
+ const newIds = ids.filter((id) => !prevSet.has(id));
const grewByOne =
!this.isFirstRun &&
ids.length === prev.length + 1 &&
newIds.length === 1 &&
- prev.every(id => ids.includes(id)); // no IDs disappeared
-
- const inRange = (b: Block) =>
- !range || (b.created_at >= range.from && b.created_at <= range.to);
+ prev.every((id) => ids.includes(id)); // no IDs disappeared
const styled: StyledBlock[] = [];
for (const b of allDone) {
@@ -379,61 +742,147 @@ export class TowerComponent {
});
}
+ const newInRangeId = grewByOne ? newIds[0] : null;
+ // Captured before any `_visibleBlocks.set` below — lets the cap keep
+ // currently-shown blocks that are now flying out so their exit animates.
+ const prevVisibleIds = new Set(this._visibleBlocks().map((b) => b.id));
+ const visibleLimit =
+ maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
+ let visibleStyled = styled;
+ let hiddenCount = 0;
+ if (Number.isFinite(visibleLimit)) {
+ ({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
+ styled,
+ visibleLimit,
+ newInRangeId,
+ prevVisibleIds,
+ ));
+ }
+ this.hiddenBlockCount.set(hiddenCount);
+
+ if (this.isFirstRun && animateInitialStack) {
+ const initialBlocks = visibleStyled.filter((b) => b._opacity === '1');
+ if (initialBlocks.length > 0) {
+ this.startDescendAnimation(visibleStyled, initialBlocks);
+ this.prevDoneIds = ids;
+ this.isFirstRun = false;
+ return;
+ }
+ }
+
if (grewByOne) {
const newId = newIds[0];
- const newBlock = styled.find(b => b.id === newId);
- if (newBlock && inRange(newBlock)) {
+ const newBlock = visibleStyled.find((b) => b.id === newId);
+ if (newBlock) {
// Snap newly-added in-range block to start position, then on the next
// paint flip it back to rest — that's what makes it visibly fall.
- newBlock._anim = '';
- newBlock._transform = 'translateY(500%)';
- newBlock._opacity = '0';
- this._visibleBlocks.set(styled);
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- newBlock._anim = 'descend';
- newBlock._transform = 'translateY(0)';
- newBlock._opacity = '1';
- this._visibleBlocks.set([...this._visibleBlocks()]);
- });
- });
+ this.startDescendAnimation(visibleStyled, [newBlock]);
this.prevDoneIds = ids;
this.isFirstRun = false;
return;
}
}
// existing fall-through path (no growth, first run, or new block out of range):
- this._visibleBlocks.set(styled);
+ this._visibleBlocks.set(visibleStyled);
this.prevDoneIds = ids;
this.isFirstRun = false;
}
+ private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void {
+ for (const block of blocks) {
+ block._anim = '';
+ block._transform = 'translateY(500%)';
+ block._opacity = '0';
+ }
+ this._visibleBlocks.set(visibleStyled);
+ this.requestFrame(() => {
+ this.requestFrame(() => {
+ if (this.destroyed) return;
+ this.finishDescendAnimation(blocks);
+ });
+ });
+ }
+
+ private requestFrame(callback: () => void): void {
+ if (typeof requestAnimationFrame !== 'function') {
+ callback();
+ return;
+ }
+ const id = requestAnimationFrame(() => {
+ this.animationFrames.delete(id);
+ if (!this.destroyed) callback();
+ });
+ this.animationFrames.add(id);
+ }
+
+ private finishDescendAnimation(blocks: StyledBlock[]): void {
+ for (const block of blocks) {
+ block._anim = 'descend';
+ block._transform = 'translateY(0)';
+ block._opacity = '1';
+ }
+ this._visibleBlocks.set([...this._visibleBlocks()]);
+ }
+
// ── Event handlers ─────────────────────────────────────────────────────────
onRename(event: Event): void {
const input = event.target as HTMLInputElement;
const newName = input.value.trim();
- if (newName && newName !== this.tower().name) {
+ if (!newName) {
+ input.value = this.tower().name;
+ return;
+ }
+ if (newName !== this.tower().name) {
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
+ } else {
+ input.value = this.tower().name;
}
}
onEditBlock(block: Block): void {
+ this.hoveredBlockId.set(null);
this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id });
}
+ onBlockPointerEnter(blockId: string): void {
+ this.hoveredBlockId.set(blockId);
+ }
+
+ onBlockPointerLeave(blockId: string, event: PointerEvent): void {
+ if (this.relatedTargetBelongsToBlock(event.relatedTarget, blockId)) return;
+ if (this.hoveredBlockId() === blockId) this.hoveredBlockId.set(null);
+ }
+
+ private relatedTargetBelongsToBlock(target: EventTarget | null, blockId: string): boolean {
+ const towerRoot = this.towerRoot()?.nativeElement;
+ let element = target instanceof Element ? target : null;
+
+ while (element && element !== towerRoot) {
+ if (element instanceof HTMLElement && element.dataset['blockId'] === blockId) return true;
+ element = element.parentElement;
+ }
+
+ return false;
+ }
+
/** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */
onMarkTaskDone(block: Block): void {
this.saveBlock.emit({
blockId: block.id,
- result: { tag: block.tag, description: block.description, is_done: true },
+ result: {
+ tag: block.tag,
+ description: block.description,
+ is_done: true,
+ difficulty: block.difficulty,
+ },
});
}
/** Called by the template "Add block" plus-icon. */
openAddBlock(): void {
- this.editEntry.set({ filter: 'done', activeId: null });
+ this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen()));
}
closeEdit(): void {
@@ -442,11 +891,21 @@ export class TowerComponent {
onBlockSave(ev: BlockEditSave): void {
if (ev.id === null) {
- this.addBlock.emit({ tag: ev.tag, description: ev.description, is_done: ev.is_done });
+ this.addBlock.emit({
+ tag: ev.tag,
+ description: ev.description,
+ is_done: ev.is_done,
+ difficulty: ev.difficulty,
+ });
} else {
this.saveBlock.emit({
blockId: ev.id,
- result: { tag: ev.tag, description: ev.description, is_done: ev.is_done },
+ result: {
+ tag: ev.tag,
+ description: ev.description,
+ is_done: ev.is_done,
+ difficulty: ev.difficulty,
+ },
});
}
}
@@ -458,7 +917,8 @@ export class TowerComponent {
}
onTowerSave(result: TowerSettingsResult): void {
- this.showSettings.set(false);
+ // Tower edits auto-save, so this fires on every change and must NOT close
+ // the modal — the user closes it via the exit button / backdrop.
this.updateTower.emit(result);
}
diff --git a/frontend/src/app/components/tower/tower.component.vitest.ts b/frontend/src/app/components/tower/tower.component.vitest.ts
new file mode 100644
index 0000000..495f141
--- /dev/null
+++ b/frontend/src/app/components/tower/tower.component.vitest.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from 'vitest';
+import type { StyledBlock } from './tower.component';
+import { editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component';
+
+function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
+ return {
+ id,
+ tag: 'tag',
+ description: id,
+ is_done: true,
+ difficulty,
+ created_at: 1,
+ _anim: '',
+ _transform: opacity === '1' ? 'translateY(0)' : 'translateY(500%)',
+ _opacity: opacity,
+ };
+}
+
+describe('selectVisibleStyledBlocks', () => {
+ it('reserves a capped visible slot for the newly completed block', () => {
+ const styled = [
+ block('newly-done'),
+ block('done-1'),
+ block('done-2'),
+ block('done-3'),
+ block('done-4'),
+ block('done-5'),
+ block('done-6'),
+ block('done-7'),
+ ];
+
+ const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
+
+ expect(result.hiddenCount).toBe(2);
+ expect(result.visibleStyled.map((b) => b.id)).toEqual([
+ 'newly-done',
+ 'done-3',
+ 'done-4',
+ 'done-5',
+ 'done-6',
+ 'done-7',
+ ]);
+ });
+
+ it('uses the normal capped window when no new block is entering', () => {
+ const styled = [
+ block('done-0'),
+ block('done-1'),
+ block('done-2'),
+ block('done-3'),
+ block('done-4'),
+ block('done-5'),
+ block('done-6'),
+ block('done-7'),
+ ];
+
+ const result = selectVisibleStyledBlocks(styled, 6, null);
+
+ expect(result.hiddenCount).toBe(2);
+ expect(result.visibleStyled.map((b) => b.id)).toEqual([
+ 'done-2',
+ 'done-3',
+ 'done-4',
+ 'done-5',
+ 'done-6',
+ 'done-7',
+ ]);
+ });
+
+ it('caps by square cost (difficulty), not by raw block count', () => {
+ // Each block draws 2 squares, so only 3 blocks fit in 6 square slots.
+ const hard = (id: string): StyledBlock => block(id, '1', 2);
+ const styled = [hard('a'), hard('b'), hard('c'), hard('d'), hard('e')];
+
+ const result = selectVisibleStyledBlocks(styled, 6, null);
+
+ expect(result.visibleStyled.map((b) => b.id)).toEqual(['c', 'd', 'e']);
+ expect(result.hiddenCount).toBe(4);
+ });
+
+ it('keeps a previously-visible block that is now flying out, even on a full stack', () => {
+ // Budget is full with three resting blocks; a fourth block has just left the
+ // range (opacity 0 → ascending). It was visible a moment ago, so it must stay
+ // rendered so its fly-up transition can play instead of vanishing instantly.
+ const styled = [
+ block('old-1'),
+ block('old-2'),
+ block('old-3'),
+ block('leaving', '0'),
+ ];
+ const prevVisible = new Set(['old-1', 'old-2', 'old-3', 'leaving']);
+
+ const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible);
+
+ expect(result.visibleStyled.map((b) => b.id)).toContain('leaving');
+ expect(result.hiddenCount).toBe(0);
+ });
+
+ it('does not resurrect an off-screen block that leaves the range', () => {
+ // 'hidden-leaving' was never rendered (not in prevVisible) — nothing to
+ // animate from, so keep it out and avoid an unbounded phantom render set.
+ const styled = [
+ block('old-1'),
+ block('old-2'),
+ block('old-3'),
+ block('hidden-leaving', '0'),
+ ];
+ const prevVisible = new Set(['old-1', 'old-2', 'old-3']);
+
+ const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible);
+
+ expect(result.visibleStyled.map((b) => b.id)).not.toContain('hidden-leaving');
+ });
+
+ it('counts hidden squares when reserving the entering block', () => {
+ const styled = [
+ block('newly-done', '1', 3),
+ block('done-1', '1', 2),
+ block('done-2', '1', 2),
+ block('done-3', '1', 2),
+ block('done-4', '1', 2),
+ ];
+
+ const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
+
+ expect(result.hiddenCount).toBe(6);
+ expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']);
+ });
+});
+
+describe('editEntryForNewBlock', () => {
+ it('opens the create card in the pending task view when tasks are kept open', () => {
+ expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null });
+ });
+
+ it('keeps the existing completed-task default when tasks are collapsed', () => {
+ expect(editEntryForNewBlock(false)).toEqual({ filter: 'done', activeId: null });
+ });
+});