diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 81da198..07f12f1 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -13,7 +13,16 @@ import { getColorOfTag } from '../../utils/color'; imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+
`, styles: ` @import '../../../library/main'; @@ -27,7 +36,16 @@ import { getColorOfTag } from '../../utils/color'; position: absolute; width: 100%; height: 100%; - @include gravitate(); + cursor: pointer; + + @media (hover: hover) and (pointer: fine) { + transition: transform $long-animation-time; + + &:hover, + &.hovered { + transform: translateY(4px); + } + } } } `, @@ -35,6 +53,7 @@ import { getColorOfTag } from '../../utils/color'; export class BlockComponent { readonly block = input.required(); readonly baseColor = input.required(); + readonly hovered = input(false); /** Emits when the square is clicked — parent opens the block-edit modal. */ readonly clicked = output(); diff --git a/frontend/src/app/components/tower/tower.component.ts b/frontend/src/app/components/tower/tower.component.ts index 7bcf4d1..4dc87d0 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -7,6 +7,10 @@ import { computed, effect, untracked, + AfterViewInit, + OnDestroy, + ElementRef, + viewChild, } from '@angular/core'; import { Tower, Block } from '../../models'; import { BlockComponent } from '../block/block.component'; @@ -17,67 +21,191 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett import { toCss } from '../../utils/color'; /** Tracks which entry path the block-edit modal was opened from. */ -type EditEntry = { filter: 'done' | 'pending'; activeId: string | null }; +export interface EditEntry { + filter: 'done' | 'pending'; + activeId: string | null; +} + +export function editEntryForNewBlock(keepTasksOpen: boolean): EditEntry { + return { + filter: keepTasksOpen ? 'pending' : 'done', + activeId: null, + }; +} /** A done block augmented with per-render animation state. */ -interface StyledBlock extends Block { +export interface StyledBlock extends Block { _anim: '' | 'descend' | 'ascend'; _transform: string; _opacity: string; } +/** One rendered square. A block draws `difficulty` squares, all sharing the + * block's color + animation state. */ +interface RenderSquare { + key: string; + block: StyledBlock; +} + +const BLOCKS_PER_ROW = 6; + +/** How many squares a block draws (its difficulty, clamped to >= 1). */ +function squareCount(block: Block): number { + const n = Math.floor(block.difficulty ?? 1); + return Number.isFinite(n) && n > 0 ? n : 1; +} + +function totalSquares(blocks: Block[]): number { + return blocks.reduce((sum, block) => sum + squareCount(block), 0); +} + +/** Pick the newest blocks (array tail) whose cumulative square-count (each + * block costs `difficulty` squares) fits within `limit`, preserving order. */ +function fitNewestBySquares(blocks: StyledBlock[], limit: number): StyledBlock[] { + const chosen: StyledBlock[] = []; + let used = 0; + for (let i = blocks.length - 1; i >= 0; i--) { + const cost = squareCount(blocks[i]); + if (used + cost > limit) break; + used += cost; + chosen.unshift(blocks[i]); + } + return chosen; +} + +export function selectVisibleStyledBlocks( + styled: StyledBlock[], + visibleLimit: number, + enteringInRangeId: string | null, + prevVisibleIds: ReadonlySet = new Set(), +): { visibleStyled: StyledBlock[]; hiddenCount: number } { + // `visibleLimit` is a number of SQUARE slots. A block draws `difficulty` + // squares, so we cap by cumulative square cost, not by raw block count. + const normalizedLimit = Math.max(0, visibleLimit); + const restingBlocks = styled.filter((b) => b._opacity === '1'); + let shownRestingBlocks = fitNewestBySquares(restingBlocks, normalizedLimit); + const enteringBlock = + enteringInRangeId === null ? undefined : restingBlocks.find((b) => b.id === enteringInRangeId); + + if (enteringBlock && !shownRestingBlocks.some((b) => b.id === enteringBlock.id)) { + // Guarantee the just-completed block a slot, then fill the remaining + // square budget with the newest of the others. + const reservedBudget = Math.max(0, normalizedLimit - squareCount(enteringBlock)); + const others = restingBlocks.filter((b) => b.id !== enteringBlock.id); + shownRestingBlocks = [enteringBlock, ...fitNewestBySquares(others, reservedBudget)]; + } + + const hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks)); + + // Blocks leaving past the upper date bound (opacity 0, `_anim: 'ascend'`) must + // stay in the render list so their fly-up transition actually plays — even + // when the resting stack already fills the whole square budget. Without this, + // a capped stack (e.g. the example page) destroys the element the instant the + // slider hides it and it just vanishes. Restrict to blocks that were visible a + // moment ago: ones already off-screen have nothing to animate from, so leaving + // them out keeps the rendered set (and the phantom flex slots) bounded. + const exitingBlocks = styled.filter((b) => b._opacity !== '1' && prevVisibleIds.has(b.id)); + + const shownIds = new Set([ + ...shownRestingBlocks.map((b) => b.id), + ...exitingBlocks.map((b) => b.id), + ]); + + return { + hiddenCount, + visibleStyled: styled.filter((b) => shownIds.has(b.id)), + }; +} + @Component({ selector: 'lt-tower', standalone: true, imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-
+
+
+ + + +
+ +
- Add block +
+ Add block -
-
- @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 }); + }); +});