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: [], 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';
@ -30,7 +39,12 @@ import { getColorOfTag } from '../../utils/color';
cursor: pointer; cursor: pointer;
@media (hover: hover) and (pointer: fine) { @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 { 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>();

View file

@ -70,6 +70,7 @@ export function selectVisibleStyledBlocks(
styled: StyledBlock[], styled: StyledBlock[],
visibleLimit: number, visibleLimit: number,
enteringInRangeId: string | null, enteringInRangeId: string | null,
prevVisibleIds: ReadonlySet<string> = new Set(),
): { visibleStyled: StyledBlock[]; hiddenCount: number } { ): { visibleStyled: StyledBlock[]; hiddenCount: number } {
// `visibleLimit` is a number of SQUARE slots. A block draws `difficulty` // `visibleLimit` is a number of SQUARE slots. A block draws `difficulty`
// squares, so we cap by cumulative square cost, not by raw block count. // 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 hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks));
const usedSquares = totalSquares(shownRestingBlocks);
const remainingBudget = Math.max(0, normalizedLimit - usedSquares); // Blocks leaving past the upper date bound (opacity 0, `_anim: 'ascend'`) must
const transitioningBlocks = // stay in the render list so their fly-up transition actually plays — even
remainingBudget > 0 // when the resting stack already fills the whole square budget. Without this,
? fitNewestBySquares( // a capped stack (e.g. the example page) destroys the element the instant the
styled.filter((b) => b._opacity !== '1'), // slider hides it and it just vanishes. Restrict to blocks that were visible a
remainingBudget, // 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([ const shownIds = new Set([
...shownRestingBlocks.map((b) => b.id), ...shownRestingBlocks.map((b) => b.id),
...transitioningBlocks.map((b) => b.id), ...exitingBlocks.map((b) => b.id),
]); ]);
return { return {
@ -152,7 +154,6 @@ export function selectVisibleStyledBlocks(
<div <div
#stackZone #stackZone
class="stack-zone" class="stack-zone"
[class.empty]="tower().blocks.length === 0"
[style.--block-stack-height]="blockStackHeight()" [style.--block-stack-height]="blockStackHeight()"
> >
<img <img
@ -173,9 +174,14 @@ export function selectVisibleStyledBlocks(
<lt-block <lt-block
[block]="sq.block" [block]="sq.block"
[baseColor]="tower().base_color" [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.transform]="sq.block._transform"
[style.opacity]="sq.block._opacity" [style.opacity]="sq.block._opacity"
(pointerenter)="onBlockPointerEnter(sq.block.id)"
(pointerleave)="onBlockPointerLeave(sq.block.id, $event)"
(clicked)="onEditBlock(sq.block)" (clicked)="onEditBlock(sq.block)"
/> />
} }
@ -290,7 +296,7 @@ export function selectVisibleStyledBlocks(
--block-stack-height: 0px; --block-stack-height: 0px;
--add-block-size: 48px; --add-block-size: 48px;
--add-block-clearance: var(--medium-padding); --add-block-clearance: var(--medium-padding);
--empty-add-block-center-offset: 0px; --add-block-center-offset: 0px;
@include card(); @include card();
overflow: hidden; overflow: hidden;
@ -372,23 +378,13 @@ export function selectVisibleStyledBlocks(
top: max( top: max(
0px, 0px,
min( 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)) calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size))
) )
); );
transform: translateX(-50%); 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 { .block-container-container {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -544,6 +540,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
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 hiddenBlockCount = signal(0);
readonly hoveredBlockId = signal<string | null>(null);
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone'); private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot'); private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
@ -656,7 +653,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
const styles = getComputedStyle(stackZone); const styles = getComputedStyle(stackZone);
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48); const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
this.measureEmptyAddBlockOffset(stackZone); this.measureAddBlockCenterOffset(stackZone);
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15; const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
const clearance = this.parseCssPixels( const clearance = this.parseCssPixels(
styles.getPropertyValue('--add-block-clearance'), 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); 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; const towerRoot = this.towerRoot()?.nativeElement;
if (!towerRoot) return; if (!towerRoot) return;
@ -678,7 +675,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
const stackCenter = stackRect.top + stackRect.height / 2; const stackCenter = stackRect.top + stackRect.height / 2;
const towerCenter = towerRect.top + towerRect.height / 2; const towerCenter = towerRect.top + towerRect.height / 2;
const offset = Math.max(0, stackCenter - towerCenter); 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 { private parseCssPixels(value: string, fallback: number): number {
@ -737,6 +734,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
} }
const newInRangeId = grewByOne ? newIds[0] : null; 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 = const visibleLimit =
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks); maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
let visibleStyled = styled; let visibleStyled = styled;
@ -746,6 +746,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
styled, styled,
visibleLimit, visibleLimit,
newInRangeId, newInRangeId,
prevVisibleIds,
)); ));
} }
this.hiddenBlockCount.set(hiddenCount); this.hiddenBlockCount.set(hiddenCount);
@ -809,9 +810,31 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
} }
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({

View file

@ -78,6 +78,40 @@ describe('selectVisibleStyledBlocks', () => {
expect(result.hiddenCount).toBe(4); 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', () => { it('counts hidden squares when reserving the entering block', () => {
const styled = [ const styled = [
block('newly-done', '1', 3), block('newly-done', '1', 3),

View file

@ -89,15 +89,6 @@ import { A11yModule } from '@angular/cdk/a11y';
</div> </div>
</dl> </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"> <div class="actions">
<button type="button" (click)="startFresh.emit()">Start empty</button> <button type="button" (click)="startFresh.emit()">Start empty</button>
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</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 { .preview-stack {
display: grid; display: flex;
grid-template-columns: repeat(3, 1fr); flex-flow: row wrap-reverse;
align-content: end; align-content: flex-start;
gap: 2px; gap: 2px;
min-height: 86px; min-height: 86px;
margin-top: auto; margin-top: auto;
span { span {
display: block; display: block;
flex: 0 0 calc((100% - 4px) / 3);
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 2px; border-radius: 2px;
background: var(--block-color); background: var(--block-color);
@ -349,9 +341,7 @@ import { A11yModule } from '@angular/cdk/a11y';
line-height: inherit; line-height: inherit;
} }
.basic__text, .basic__text {
.account-note,
.choice-note {
font-family: inherit; font-family: inherit;
font-weight: inherit; font-weight: inherit;
color: rgba($text-color, 0.82); color: rgba($text-color, 0.82);
@ -359,16 +349,6 @@ import { A11yModule } from '@angular/cdk/a11y';
line-height: inherit; line-height: inherit;
} }
.account-note,
.choice-note {
max-width: 54ch;
margin-inline: auto;
}
.choice-note {
text-align: center;
}
.actions { .actions {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;

View file

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

View file

@ -280,7 +280,7 @@ describe('StoreService', () => {
expect(blocks[2].created_at).toBe(100); 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 api = makeMockApi();
const store = configure(api); const store = configure(api);
@ -292,13 +292,13 @@ describe('StoreService', () => {
expect(page.towers).toHaveLength(3); expect(page.towers).toHaveLength(3);
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done)); 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) expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size)
.toBeGreaterThan(1); .toBeGreaterThan(1);
for (const tower of page.towers) { for (const tower of page.towers) {
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at); 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(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1); expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
} }