good
Some checks failed
CI / Backend tests (pull_request) Has been cancelled
CI / Frontend lint (pull_request) Has been cancelled
CI / Frontend unit tests (pull_request) Has been cancelled
CI / Frontend build (pull_request) Has been cancelled
CI / Playwright e2e (pull_request) Has been cancelled

This commit is contained in:
Andras Schmelczer 2026-05-30 15:55:29 +01:00
parent 5ac6633d40
commit 617e3ef16d
6 changed files with 147 additions and 95 deletions

View file

@ -13,7 +13,16 @@ import { getColorOfTag } from '../../utils/color';
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
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: `
@import '../../../library/main';
@ -30,7 +39,12 @@ import { getColorOfTag } from '../../utils/color';
cursor: pointer;
@media (hover: hover) and (pointer: fine) {
@include gravitate();
transition: transform $long-animation-time;
&:hover,
&.hovered {
transform: translateY(4px);
}
}
}
}
@ -39,6 +53,7 @@ import { getColorOfTag } from '../../utils/color';
export class BlockComponent {
readonly block = input.required<Block>();
readonly baseColor = input.required<HslColor>();
readonly hovered = input(false);
/** Emits when the square is clicked — parent opens the block-edit modal. */
readonly clicked = output<void>();

View file

@ -70,6 +70,7 @@ 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.
@ -88,18 +89,19 @@ export function selectVisibleStyledBlocks(
}
const hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks));
const usedSquares = totalSquares(shownRestingBlocks);
const remainingBudget = Math.max(0, normalizedLimit - usedSquares);
const transitioningBlocks =
remainingBudget > 0
? fitNewestBySquares(
styled.filter((b) => b._opacity !== '1'),
remainingBudget,
)
: [];
// 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),
...transitioningBlocks.map((b) => b.id),
...exitingBlocks.map((b) => b.id),
]);
return {
@ -152,7 +154,6 @@ export function selectVisibleStyledBlocks(
<div
#stackZone
class="stack-zone"
[class.empty]="tower().blocks.length === 0"
[style.--block-stack-height]="blockStackHeight()"
>
<img
@ -173,9 +174,14 @@ export function selectVisibleStyledBlocks(
<lt-block
[block]="sq.block"
[baseColor]="tower().base_color"
[class]="sq.block._anim"
[hovered]="hoveredBlockId() === sq.block.id"
[attr.data-block-id]="sq.block.id"
[class.descend]="sq.block._anim === 'descend'"
[class.ascend]="sq.block._anim === 'ascend'"
[style.transform]="sq.block._transform"
[style.opacity]="sq.block._opacity"
(pointerenter)="onBlockPointerEnter(sq.block.id)"
(pointerleave)="onBlockPointerLeave(sq.block.id, $event)"
(clicked)="onEditBlock(sq.block)"
/>
}
@ -290,7 +296,7 @@ export function selectVisibleStyledBlocks(
--block-stack-height: 0px;
--add-block-size: 48px;
--add-block-clearance: var(--medium-padding);
--empty-add-block-center-offset: 0px;
--add-block-center-offset: 0px;
@include card();
overflow: hidden;
@ -372,23 +378,13 @@ export function selectVisibleStyledBlocks(
top: max(
0px,
min(
calc(50% - var(--add-block-size) / 2),
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%);
}
&.empty img.add-block {
top: max(
0px,
min(
calc(50% - var(--add-block-size) / 2 - var(--empty-add-block-center-offset)),
calc(100% - var(--add-block-size))
)
);
}
.block-container-container {
position: absolute;
inset: 0;
@ -544,6 +540,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
readonly editEntry = signal<EditEntry | null>(null);
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');
@ -656,7 +653,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
const styles = getComputedStyle(stackZone);
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
this.measureEmptyAddBlockOffset(stackZone);
this.measureAddBlockCenterOffset(stackZone);
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
const clearance = this.parseCssPixels(
styles.getPropertyValue('--add-block-clearance'),
@ -669,7 +666,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
}
private measureEmptyAddBlockOffset(stackZone: HTMLElement): void {
private measureAddBlockCenterOffset(stackZone: HTMLElement): void {
const towerRoot = this.towerRoot()?.nativeElement;
if (!towerRoot) return;
@ -678,7 +675,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
const stackCenter = stackRect.top + stackRect.height / 2;
const towerCenter = towerRect.top + towerRect.height / 2;
const offset = Math.max(0, stackCenter - towerCenter);
stackZone.style.setProperty('--empty-add-block-center-offset', `${offset}px`);
stackZone.style.setProperty('--add-block-center-offset', `${offset}px`);
}
private parseCssPixels(value: string, fallback: number): number {
@ -737,6 +734,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
}
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;
@ -746,6 +746,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
styled,
visibleLimit,
newInRangeId,
prevVisibleIds,
));
}
this.hiddenBlockCount.set(hiddenCount);
@ -809,9 +810,31 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
}
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({

View file

@ -78,6 +78,40 @@ describe('selectVisibleStyledBlocks', () => {
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),

View file

@ -89,15 +89,6 @@ import { A11yModule } from '@angular/cdk/a11y';
</div>
</dl>
<p class="account-note">
Your towers save with a private recovery token in Settings, then Account. Use it to
restore your workspace on another device.
</p>
<p class="choice-note">
Start empty if you know what to track, or load sample towers you can edit or delete.
</p>
<div class="actions">
<button type="button" (click)="startFresh.emit()">Start empty</button>
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
@ -240,15 +231,16 @@ import { A11yModule } from '@angular/cdk/a11y';
}
.preview-stack {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: end;
display: flex;
flex-flow: row wrap-reverse;
align-content: flex-start;
gap: 2px;
min-height: 86px;
margin-top: auto;
span {
display: block;
flex: 0 0 calc((100% - 4px) / 3);
aspect-ratio: 1;
border-radius: 2px;
background: var(--block-color);
@ -349,9 +341,7 @@ import { A11yModule } from '@angular/cdk/a11y';
line-height: inherit;
}
.basic__text,
.account-note,
.choice-note {
.basic__text {
font-family: inherit;
font-weight: inherit;
color: rgba($text-color, 0.82);
@ -359,16 +349,6 @@ import { A11yModule } from '@angular/cdk/a11y';
line-height: inherit;
}
.account-note,
.choice-note {
max-width: 54ch;
margin-inline: auto;
}
.choice-note {
text-align: center;
}
.actions {
display: flex;
justify-content: space-around;

View file

@ -69,7 +69,7 @@ interface ExampleDonePattern {
desc: (sequence: number) => string;
}
const EXAMPLE_DONE_BLOCKS_PER_TOWER = 120;
const EXAMPLE_DONE_BLOCKS_PER_TOWER = 12;
@Injectable({ providedIn: 'root' })
export class StoreService implements OnDestroy {
@ -333,11 +333,11 @@ export class StoreService implements OnDestroy {
pages.map((p) =>
p.id === pageId
? {
...p,
towers: p.towers.map((t) =>
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
),
}
...p,
towers: p.towers.map((t) =>
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
),
}
: p,
),
);
@ -356,16 +356,16 @@ export class StoreService implements OnDestroy {
pages.map((p) =>
p.id === pageId
? {
...p,
towers: p.towers.map((t) =>
t.id === towerId
? {
...t,
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
}
: t,
),
}
...p,
towers: p.towers.map((t) =>
t.id === towerId
? {
...t,
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
}
: t,
),
}
: p,
),
);
@ -377,13 +377,13 @@ export class StoreService implements OnDestroy {
pages.map((p) =>
p.id === pageId
? {
...p,
towers: p.towers.map((t) =>
t.id === towerId
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
: t,
),
}
...p,
towers: p.towers.map((t) =>
t.id === towerId
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
: t,
),
}
: p,
),
);
@ -396,21 +396,21 @@ export class StoreService implements OnDestroy {
pages.map((p) =>
p.id === pageId
? {
...p,
towers: p.towers.map((t) =>
t.id === towerId
? (() => {
const block = t.blocks.find((b) => b.id === blockId);
if (!block) return t;
const result = this.patchBlockList(t.blocks, blockId, {
is_done: !block.is_done,
});
becameDone = result.becameDone;
return { ...t, blocks: result.blocks };
})()
: t,
),
}
...p,
towers: p.towers.map((t) =>
t.id === towerId
? (() => {
const block = t.blocks.find((b) => b.id === blockId);
if (!block) return t;
const result = this.patchBlockList(t.blocks, blockId, {
is_done: !block.is_done,
});
becameDone = result.becameDone;
return { ...t, blocks: result.blocks };
})()
: t,
),
}
: p,
),
);

View file

@ -280,7 +280,7 @@ describe('StoreService', () => {
expect(blocks[2].created_at).toBe(100);
});
it('loads welcome example data with hundreds of completed squares', () => {
it('loads welcome example data with a stack of completed squares per tower', () => {
const api = makeMockApi();
const store = configure(api);
@ -292,13 +292,13 @@ describe('StoreService', () => {
expect(page.towers).toHaveLength(3);
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
expect(doneBlocks.length).toBeGreaterThanOrEqual(300);
expect(doneBlocks.length).toBeGreaterThanOrEqual(90);
expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size)
.toBeGreaterThan(1);
for (const tower of page.towers) {
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
expect(doneDates.length).toBeGreaterThanOrEqual(100);
expect(doneDates.length).toBeGreaterThanOrEqual(30);
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
}