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
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:
parent
5ac6633d40
commit
617e3ef16d
6 changed files with 147 additions and 95 deletions
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue