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: [],
|
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>();
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue