Modernise & deploy #1
3 changed files with 743 additions and 125 deletions
|
|
@ -13,7 +13,16 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
imports: [],
|
imports: [],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div [style.background-color]="color()" (click)="clicked.emit()"></div>
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Edit completed task"
|
||||||
|
[class.hovered]="hovered()"
|
||||||
|
[style.background-color]="color()"
|
||||||
|
(click)="clicked.emit()"
|
||||||
|
(keydown.enter)="clicked.emit()"
|
||||||
|
(keydown.space)="$event.preventDefault(); clicked.emit()"
|
||||||
|
></div>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
@import '../../../library/main';
|
@import '../../../library/main';
|
||||||
|
|
@ -27,7 +36,16 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
export class BlockComponent {
|
||||||
readonly block = input.required<Block>();
|
readonly block = input.required<Block>();
|
||||||
readonly baseColor = input.required<HslColor>();
|
readonly baseColor = input.required<HslColor>();
|
||||||
|
readonly hovered = input(false);
|
||||||
|
|
||||||
/** Emits when the square is clicked — parent opens the block-edit modal. */
|
/** Emits when the square is clicked — parent opens the block-edit modal. */
|
||||||
readonly clicked = output<void>();
|
readonly clicked = output<void>();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import {
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
untracked,
|
untracked,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Tower, Block } from '../../models';
|
import { Tower, Block } from '../../models';
|
||||||
import { BlockComponent } from '../block/block.component';
|
import { BlockComponent } from '../block/block.component';
|
||||||
|
|
@ -17,67 +21,191 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
|
||||||
import { toCss } from '../../utils/color';
|
import { toCss } from '../../utils/color';
|
||||||
|
|
||||||
/** Tracks which entry path the block-edit modal was opened from. */
|
/** 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. */
|
/** A done block augmented with per-render animation state. */
|
||||||
interface StyledBlock extends Block {
|
export interface StyledBlock extends Block {
|
||||||
_anim: '' | 'descend' | 'ascend';
|
_anim: '' | 'descend' | 'ascend';
|
||||||
_transform: string;
|
_transform: string;
|
||||||
_opacity: 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<string> = 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({
|
@Component({
|
||||||
selector: 'lt-tower',
|
selector: 'lt-tower',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="tower">
|
<div #towerRoot class="tower">
|
||||||
<div class="container" [class.trash-highlight]="trashHighlight()">
|
<div class="tower-header">
|
||||||
|
<input
|
||||||
|
#nameInput
|
||||||
|
type="text"
|
||||||
|
[value]="tower().name"
|
||||||
|
[style.color]="towerNameCss()"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(blur)="onRename($event)"
|
||||||
|
(keydown.enter)="nameInput.blur()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="edit-tower"
|
||||||
|
aria-label="Edit tower"
|
||||||
|
(click)="$event.stopPropagation(); showSettings.set(true)"
|
||||||
|
>
|
||||||
|
<img src="assets/pen.svg" alt="" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
<lt-tasks
|
<lt-tasks
|
||||||
[pending]="pending()"
|
[pending]="pending()"
|
||||||
[baseColor]="tower().base_color"
|
[baseColor]="tower().base_color"
|
||||||
[initiallyOpen]="keepTasksOpen()"
|
[initiallyOpen]="keepTasksOpen()"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
(markDone)="onMarkTaskDone($event)"
|
(markDone)="onMarkTaskDone($event)"
|
||||||
(edit)="onEditBlock($event)"
|
(edit)="onEditBlock($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<img
|
<div
|
||||||
src="assets/plus-sign.svg"
|
#stackZone
|
||||||
class="add-block"
|
class="stack-zone"
|
||||||
alt="Add block"
|
[style.--block-stack-height]="blockStackHeight()"
|
||||||
(click)="$event.stopPropagation(); openAddBlock()"
|
>
|
||||||
/>
|
<img
|
||||||
|
src="assets/plus-sign.svg"
|
||||||
|
class="add-block"
|
||||||
|
alt="Add block"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(click)="$event.stopPropagation(); openAddBlock()"
|
||||||
|
(keydown.enter)="$event.stopPropagation(); openAddBlock()"
|
||||||
|
(keydown.space)="$event.preventDefault(); $event.stopPropagation(); openAddBlock()"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="block-container-container">
|
<div class="block-container-container">
|
||||||
<div class="block-container">
|
<div class="block-container">
|
||||||
@for (b of visibleBlocks(); track b.id) {
|
@for (sq of squares(); track sq.key; let i = $index) {
|
||||||
<lt-block
|
<lt-block
|
||||||
[block]="b"
|
[block]="sq.block"
|
||||||
[baseColor]="tower().base_color"
|
[baseColor]="tower().base_color"
|
||||||
[class]="b._anim"
|
[hovered]="hoveredBlockId() === sq.block.id"
|
||||||
[style.transform]="b._transform"
|
[attr.data-block-id]="sq.block.id"
|
||||||
[style.opacity]="b._opacity"
|
[class.descend]="sq.block._anim === 'descend'"
|
||||||
(clicked)="onEditBlock(b)"
|
[class.ascend]="sq.block._anim === 'ascend'"
|
||||||
/>
|
[style.transform]="sq.block._transform"
|
||||||
}
|
[style.opacity]="sq.block._opacity"
|
||||||
|
[style.z-index]="squares().length - i"
|
||||||
|
(pointerenter)="onBlockPointerEnter(sq.block.id)"
|
||||||
|
(pointerleave)="onBlockPointerLeave(sq.block.id, $event)"
|
||||||
|
(clicked)="onEditBlock(sq.block)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
@if (hiddenBlockCount() > 0) {
|
||||||
#nameInput
|
<p class="more-blocks">+ {{ hiddenBlockCount() }} more</p>
|
||||||
type="text"
|
}
|
||||||
[value]="tower().name"
|
|
||||||
[style.color]="towerNameCss()"
|
|
||||||
(blur)="onRename($event)"
|
|
||||||
(keydown.enter)="nameInput.blur()"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (editEntry(); as entry) {
|
@if (editEntry(); as entry) {
|
||||||
<lt-modal (close)="closeEdit()">
|
<lt-modal (close)="closeEdit()">
|
||||||
<lt-block-edit
|
<lt-block-edit
|
||||||
|
[viewTitle]="editViewTitle()"
|
||||||
[blocks]="filteredForEntry()"
|
[blocks]="filteredForEntry()"
|
||||||
[activeBlockId]="entry.activeId"
|
[activeBlockId]="entry.activeId"
|
||||||
[tags]="towerTags()"
|
[tags]="towerTags()"
|
||||||
|
|
@ -92,7 +220,12 @@ interface StyledBlock extends Block {
|
||||||
|
|
||||||
@if (showSettings()) {
|
@if (showSettings()) {
|
||||||
<lt-modal (close)="showSettings.set(false)">
|
<lt-modal (close)="showSettings.set(false)">
|
||||||
<lt-tower-settings [tower]="tower()" (save)="onTowerSave($event)" (delete)="onTowerDelete()" />
|
<lt-tower-settings
|
||||||
|
[tower]="tower()"
|
||||||
|
(save)="onTowerSave($event)"
|
||||||
|
(delete)="onTowerDelete()"
|
||||||
|
(close)="showSettings.set(false)"
|
||||||
|
/>
|
||||||
</lt-modal>
|
</lt-modal>
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|
@ -100,7 +233,9 @@ interface StyledBlock extends Block {
|
||||||
@import '../../../library/main';
|
@import '../../../library/main';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
&.cdk-drag-animating {
|
&.cdk-drag-animating {
|
||||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
|
@ -135,12 +270,12 @@ interface StyledBlock extends Block {
|
||||||
transform: scale(0.75);
|
transform: scale(0.75);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
:before {
|
&::before {
|
||||||
opacity: 0.5 !important;
|
opacity: 0.5 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
.tower-header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,8 +284,10 @@ interface StyledBlock extends Block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
@include inner-spacing(var(--small-padding));
|
@include inner-spacing(var(--small-padding));
|
||||||
|
|
||||||
|
|
@ -158,7 +295,15 @@ interface StyledBlock extends Block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
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();
|
@include card();
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -166,9 +311,16 @@ interface StyledBlock extends Block {
|
||||||
|
|
||||||
@include inner-spacing(var(--medium-padding));
|
@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%;
|
width: 100%;
|
||||||
|
|
||||||
:before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -184,108 +336,206 @@ interface StyledBlock extends Block {
|
||||||
}
|
}
|
||||||
|
|
||||||
lt-tasks {
|
lt-tasks {
|
||||||
flex: 0 0 auto;
|
flex: 0 1 auto;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
max-height: 30vh;
|
max-height: min(30vh, 45%);
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: min(25vh, 45%);
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
img.add-block {
|
.stack-zone {
|
||||||
flex: 0 0 auto;
|
|
||||||
align-self: center;
|
|
||||||
margin: var(--medium-padding) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
flex: 1 1 auto;
|
||||||
height: 48px;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
img {
|
||||||
height: 32px;
|
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;
|
img.add-block {
|
||||||
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;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
z-index: 3;
|
||||||
width: 100%;
|
left: 50%;
|
||||||
transform: scaleY(-1);
|
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 */
|
.block-container-container {
|
||||||
* {
|
position: absolute;
|
||||||
transform: translateY(500%);
|
inset: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.descend {
|
.block-container {
|
||||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
display: flex;
|
||||||
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
|
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 {
|
/* Default resting position for all blocks before JS sets them */
|
||||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
* {
|
||||||
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
|
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'] {
|
.more-blocks {
|
||||||
font-size: var(--small-font-size);
|
@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;
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: $mobile-width) {
|
.tower-header {
|
||||||
width: 50%;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||||
readonly tower = input.required<Tower>();
|
readonly tower = input.required<Tower>();
|
||||||
/** Set by page component when this tower is being dragged over trash. */
|
|
||||||
readonly trashHighlight = input<boolean>(false);
|
|
||||||
/** Optional date range filter — when set, blocks with `created_at`
|
/** Optional date range filter — when set, blocks with `created_at`
|
||||||
* outside [from, to] are hidden from the falling stack. */
|
* outside [from, to] are hidden from the falling stack. */
|
||||||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||||
/** When true, the tasks accordion starts expanded on load. */
|
/** When true, the tasks accordion starts expanded on load. */
|
||||||
readonly keepTasksOpen = input<boolean>(false);
|
readonly keepTasksOpen = input<boolean>(false);
|
||||||
|
/** When true, completed blocks descend on this tower's first measured render. */
|
||||||
|
readonly animateInitialStack = input<boolean>(false);
|
||||||
|
|
||||||
// ── Outputs ────────────────────────────────────────────────────────────────
|
// ── Outputs ────────────────────────────────────────────────────────────────
|
||||||
readonly updateTower = output<TowerSettingsResult>();
|
readonly updateTower = output<TowerSettingsResult>();
|
||||||
readonly deleteTowerRequest = output<void>();
|
readonly deleteTowerRequest = output<void>();
|
||||||
/** Emitted when a new block is created from the carousel's "Create now" card. */
|
/** 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. */
|
/** Emitted when an existing block is patched from the carousel. */
|
||||||
readonly saveBlock = output<{
|
readonly saveBlock = output<{
|
||||||
blockId: string;
|
blockId: string;
|
||||||
result: { tag: string; description: string; is_done: boolean };
|
result: { tag: string; description: string; is_done: boolean; difficulty: number };
|
||||||
}>();
|
}>();
|
||||||
readonly deleteBlock = output<string>();
|
readonly deleteBlock = output<string>();
|
||||||
|
|
||||||
|
|
@ -294,10 +544,19 @@ export class TowerComponent {
|
||||||
* which list of blocks to show and which one to focus initially. */
|
* which list of blocks to show and which one to focus initially. */
|
||||||
readonly editEntry = signal<EditEntry | null>(null);
|
readonly editEntry = signal<EditEntry | null>(null);
|
||||||
readonly showSettings = signal(false);
|
readonly showSettings = signal(false);
|
||||||
|
readonly hiddenBlockCount = signal(0);
|
||||||
|
readonly hoveredBlockId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
|
||||||
|
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||||
|
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
private destroyed = false;
|
||||||
|
private readonly animationFrames = new Set<number>();
|
||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
/** 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. */
|
/** CSS color string for the tower name input. */
|
||||||
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
||||||
|
|
@ -307,7 +566,14 @@ export class TowerComponent {
|
||||||
const entry = this.editEntry();
|
const entry = this.editEntry();
|
||||||
if (!entry) return [];
|
if (!entry) return [];
|
||||||
const isDone = entry.filter === 'done';
|
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. */
|
/** Unique tags from existing blocks of this tower. */
|
||||||
|
|
@ -326,33 +592,130 @@ export class TowerComponent {
|
||||||
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
||||||
readonly visibleBlocks = this._visibleBlocks.asReadonly();
|
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<RenderSquare[]>(() => {
|
||||||
|
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 prevDoneIds: string[] = [];
|
||||||
private isFirstRun = true;
|
private isFirstRun = true;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const range = this.dateRange();
|
const range = this.dateRange();
|
||||||
// Always keep ALL done blocks in the visible list. The date filter
|
const maxVisibleBlocks = this.maxVisibleBlocks();
|
||||||
// only flips per-block `_anim` so the 1.5s ascend/descend transition
|
const animateInitialStack = this.animateInitialStack();
|
||||||
// can animate them in and out of the falling stack.
|
// 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);
|
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 ids = allDone.map((b) => b.id);
|
||||||
const prev = this.prevDoneIds;
|
const prev = this.prevDoneIds;
|
||||||
const prevSet = new Set(prev);
|
const prevSet = new Set(prev);
|
||||||
const newIds = ids.filter(id => !prevSet.has(id));
|
const newIds = ids.filter((id) => !prevSet.has(id));
|
||||||
const grewByOne =
|
const grewByOne =
|
||||||
!this.isFirstRun &&
|
!this.isFirstRun &&
|
||||||
ids.length === prev.length + 1 &&
|
ids.length === prev.length + 1 &&
|
||||||
newIds.length === 1 &&
|
newIds.length === 1 &&
|
||||||
prev.every(id => ids.includes(id)); // no IDs disappeared
|
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||||
|
|
||||||
const inRange = (b: Block) =>
|
|
||||||
!range || (b.created_at >= range.from && b.created_at <= range.to);
|
|
||||||
|
|
||||||
const styled: StyledBlock[] = [];
|
const styled: StyledBlock[] = [];
|
||||||
for (const b of allDone) {
|
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) {
|
if (grewByOne) {
|
||||||
const newId = newIds[0];
|
const newId = newIds[0];
|
||||||
const newBlock = styled.find(b => b.id === newId);
|
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||||
if (newBlock && inRange(newBlock)) {
|
if (newBlock) {
|
||||||
// Snap newly-added in-range block to start position, then on the next
|
// 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.
|
// paint flip it back to rest — that's what makes it visibly fall.
|
||||||
newBlock._anim = '';
|
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||||
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.prevDoneIds = ids;
|
this.prevDoneIds = ids;
|
||||||
this.isFirstRun = false;
|
this.isFirstRun = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// existing fall-through path (no growth, first run, or new block out of range):
|
// 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.prevDoneIds = ids;
|
||||||
this.isFirstRun = false;
|
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 ─────────────────────────────────────────────────────────
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onRename(event: Event): void {
|
onRename(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const newName = input.value.trim();
|
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 });
|
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||||
|
} else {
|
||||||
|
input.value = this.tower().name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEditBlock(block: Block): void {
|
onEditBlock(block: Block): void {
|
||||||
|
this.hoveredBlockId.set(null);
|
||||||
this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id });
|
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. */
|
/** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */
|
||||||
onMarkTaskDone(block: Block): void {
|
onMarkTaskDone(block: Block): void {
|
||||||
this.saveBlock.emit({
|
this.saveBlock.emit({
|
||||||
blockId: block.id,
|
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. */
|
/** Called by the template "Add block" plus-icon. */
|
||||||
openAddBlock(): void {
|
openAddBlock(): void {
|
||||||
this.editEntry.set({ filter: 'done', activeId: null });
|
this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen()));
|
||||||
}
|
}
|
||||||
|
|
||||||
closeEdit(): void {
|
closeEdit(): void {
|
||||||
|
|
@ -442,11 +891,21 @@ export class TowerComponent {
|
||||||
|
|
||||||
onBlockSave(ev: BlockEditSave): void {
|
onBlockSave(ev: BlockEditSave): void {
|
||||||
if (ev.id === null) {
|
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 {
|
} else {
|
||||||
this.saveBlock.emit({
|
this.saveBlock.emit({
|
||||||
blockId: ev.id,
|
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 {
|
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);
|
this.updateTower.emit(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
139
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
139
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue