getting there

This commit is contained in:
Andras Schmelczer 2026-05-30 15:40:53 +01:00
parent f87480e79d
commit 5ac6633d40
20 changed files with 898 additions and 112 deletions

View file

@ -23,12 +23,14 @@ export interface BlockEditSave {
tag: string;
description: string;
is_done: boolean;
difficulty: number;
}
interface EditedValue {
tag: string;
description: string;
is_done: boolean;
difficulty: number;
}
@Component({
@ -73,8 +75,7 @@ interface EditedValue {
[selected]="editedFor(b.id).tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
placeholder="Tag this item…"
emptyHint="No tags yet. Type one below to create it."
[placeholder]="tagPlaceholder('Tag this item…')"
(select)="updateTag(b.id, $event)"
(add)="updateTag(b.id, $event)"
/>
@ -96,6 +97,26 @@ interface EditedValue {
<span>Already done</span>
</label>
<div class="difficulty">
<span class="label">Difficulty</span>
<div class="stepper">
<button
type="button"
class="step"
aria-label="Decrease difficulty"
[disabled]="editedFor(b.id).difficulty <= 1"
(click)="updateDifficulty(b.id, -1); $event.stopPropagation()"
></button>
<span class="value">{{ editedFor(b.id).difficulty }}</span>
<button
type="button"
class="step"
aria-label="Increase difficulty"
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
>+</button>
</div>
</div>
<div class="bottom">
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
</div>
@ -125,8 +146,7 @@ interface EditedValue {
[selected]="newValue().tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
placeholder="Set a category…"
emptyHint="No tags yet. Type one below to create it."
[placeholder]="tagPlaceholder('Set a category…')"
(select)="updateNewTag($event)"
(add)="updateNewTag($event)"
/>
@ -147,6 +167,26 @@ interface EditedValue {
<span>Already done</span>
</label>
<div class="difficulty">
<span class="label">Difficulty</span>
<div class="stepper">
<button
type="button"
class="step"
aria-label="Decrease difficulty"
[disabled]="newValue().difficulty <= 1"
(click)="updateNewDifficulty(-1); $event.stopPropagation()"
></button>
<span class="value">{{ newValue().difficulty }}</span>
<button
type="button"
class="step"
aria-label="Increase difficulty"
(click)="updateNewDifficulty(1); $event.stopPropagation()"
>+</button>
</div>
</div>
<div class="bottom">
<button
(click)="submitNew(); $event.stopPropagation()"
@ -394,6 +434,68 @@ interface EditedValue {
}
}
.difficulty {
@include medium-text();
display: flex;
align-items: center;
justify-content: center;
gap: var(--small-padding);
width: max-content;
max-width: 100%;
margin: 0 auto var(--medium-padding);
.stepper {
display: flex;
align-items: center;
gap: var(--small-padding);
.value {
min-width: 1.5em;
text-align: center;
font-variant-numeric: tabular-nums;
}
button.step {
all: unset; // strip native + global button styles
@include square(22px);
@include center-child();
flex: 0 0 auto;
box-sizing: border-box;
border-radius: 4px;
background: $light-color;
box-shadow: $shadow-border;
cursor: pointer;
// all:unset drops the font to serif and the global button's hover
// underline (button::after) survives the reset — re-assert both.
font: bold 18px/1 $normal-font;
color: $text-color;
user-select: none;
transition: box-shadow $long-animation-time, transform $short-animation-time, opacity $short-animation-time;
&::after { content: none; }
@media (max-width: $mobile-width) {
@include square(26px);
}
&:hover,
&:focus-visible {
box-shadow: $shadow;
}
&:active {
transform: scale(0.95);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
box-shadow: $shadow-border;
}
}
}
}
.bottom {
height: 32px;
@media (max-width: $mobile-width) {
@ -454,6 +556,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: '',
description: '',
is_done: true,
difficulty: 1,
});
// 1-based index of the centered card. 0/N+2 are placeholders.
@ -471,6 +574,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: b.tag,
description: b.description,
is_done: b.is_done,
difficulty: b.difficulty ?? 1,
});
}
untracked(() => this.editedValues.set(m));
@ -510,6 +614,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: '',
description: '',
is_done: false,
difficulty: 1,
}
);
}
@ -519,6 +624,10 @@ export class BlockEditComponent implements AfterViewInit {
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
}
tagPlaceholder(fallback: string): string {
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
}
colorOfNewTag = computed(() => {
const t = this.newValue().tag;
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
@ -561,6 +670,12 @@ export class BlockEditComponent implements AfterViewInit {
this.flushExisting(id);
}
updateDifficulty(id: string, delta: number): void {
const next = Math.max(1, this.editedFor(id).difficulty + delta);
this.patchEdited(id, { difficulty: next });
this.flushExisting(id);
}
private patchEdited(id: string, patch: Partial<EditedValue>): void {
this.editedValues.update((m) => {
const v = m.get(id);
@ -574,7 +689,13 @@ export class BlockEditComponent implements AfterViewInit {
flushExisting(id: string): void {
const v = this.editedFor(id);
if (!v.tag) return; // Skip empty saves
this.save.emit({ id, tag: v.tag, description: v.description, is_done: v.is_done });
this.save.emit({
id,
tag: v.tag,
description: v.description,
is_done: v.is_done,
difficulty: v.difficulty,
});
}
onDelete(id: string): void {
@ -595,6 +716,10 @@ export class BlockEditComponent implements AfterViewInit {
this.newValue.update((v) => ({ ...v, is_done }));
}
updateNewDifficulty(delta: number): void {
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
}
submitNew(): void {
const v = this.newValue();
if (!v.tag) return;
@ -603,6 +728,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: v.tag,
description: v.description,
is_done: v.is_done,
difficulty: v.difficulty,
});
this.close.emit();
}

View file

@ -2,6 +2,7 @@ import {
Component,
ChangeDetectionStrategy,
output,
input,
signal,
AfterViewInit,
OnDestroy,
@ -23,7 +24,17 @@ import { ModalStateService } from '../../services/modal-state.service';
[class.active]="active()"
(click)="onBackdropClick($event)"
>
<div class="modal__dialog" #dialog cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="onClose()">
<div
class="modal__dialog"
#dialog
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="labelledBy()"
[attr.aria-describedby]="describedBy()"
cdkTrapFocus
cdkTrapFocusAutoCapture
(keydown.escape)="onClose()"
>
<ng-content></ng-content>
</div>
</section>
@ -73,6 +84,8 @@ import { ModalStateService } from '../../services/modal-state.service';
`,
})
export class ModalComponent implements AfterViewInit, OnDestroy {
readonly labelledBy = input<string | null>(null);
readonly describedBy = input<string | null>(null);
readonly close = output<void>();
// The active signal starts false; AfterViewInit flips it true on next tick

View file

@ -11,6 +11,7 @@
[tower]="tower"
[dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open"
[animateInitialStack]="animateInitialStack()"
(cdkDragStarted)="onTowerDragStart(tower.id)"
(updateTower)="onUpdateTower(tower.id, $event)"
(deleteTowerRequest)="onDeleteTower(tower.id)"

View file

@ -39,6 +39,7 @@ interface BlockPatch {
tag: string;
description: string;
is_done: boolean;
difficulty: number;
}
/** Minimum blocks before the date-range slider becomes visible. */
@ -61,6 +62,7 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
})
export class PageComponent {
readonly page = input.required<Page>();
readonly animateInitialStack = input<boolean>(false);
readonly dragHappening = output<boolean>();
protected readonly store = inject(StoreService);
@ -157,6 +159,7 @@ export class PageComponent {
result.tag,
result.description,
result.is_done,
result.difficulty,
);
}

View file

@ -10,7 +10,11 @@
<div class="page-container">
@if (selectedPage(); as page) {
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" />
<lt-page
[page]="page"
[animateInitialStack]="page.id === animateInitialStackPageId()"
(dragHappening)="dragHappening.set($event)"
/>
} @else {
<p>Add a new page to get started!</p>
}
@ -53,7 +57,11 @@
}
@if (showWelcome()) {
<lt-modal (close)="showWelcome.set(false)">
<lt-modal
labelledBy="welcome-title"
describedBy="welcome-description"
(close)="showWelcome.set(false)"
>
<lt-welcome
(close)="showWelcome.set(false)"
(startFresh)="showWelcome.set(false)"

View file

@ -5,6 +5,7 @@ import {
signal,
computed,
effect,
OnDestroy,
} from '@angular/core';
import { StoreService } from '../../services/store.service';
import { PageComponent } from '../page/page.component';
@ -22,7 +23,7 @@ import { Page } from '../../models';
templateUrl: './pages.component.html',
styleUrl: './pages.component.scss',
})
export class PagesComponent {
export class PagesComponent implements OnDestroy {
protected readonly store = inject(StoreService);
/** ID of currently selected page within store.pages(). */
@ -32,6 +33,8 @@ export class PagesComponent {
readonly dragHappening = signal(false);
readonly showWelcome = signal(false);
readonly confirmDeletePageId = signal<string | null>(null);
readonly animateInitialStackPageId = signal<string | null>(null);
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
effect(() => {
@ -44,10 +47,27 @@ export class PagesComponent {
}
onLoadExample(): void {
this.store.loadExample();
const pageId = this.store.loadExample();
this.selectedPageId.set(pageId);
this.animateInitialStackPageId.set(pageId);
if (this.exampleAnimationTimer !== null) {
clearTimeout(this.exampleAnimationTimer);
}
this.exampleAnimationTimer = setTimeout(() => {
if (this.animateInitialStackPageId() === pageId) {
this.animateInitialStackPageId.set(null);
}
this.exampleAnimationTimer = null;
}, 2500);
this.showWelcome.set(false);
}
ngOnDestroy(): void {
if (this.exampleAnimationTimer !== null) {
clearTimeout(this.exampleAnimationTimer);
}
}
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
readonly selectedPage = computed<Page | null>(() => {

View file

@ -28,9 +28,6 @@ import {
</div>
<div class="bottom-container">
<div class="bottom" [class.open]="open()">
@if (resolvedItems().length === 0 && emptyHint()) {
<p class="empty-hint">{{ emptyHint() }}</p>
}
@for (item of resolvedItems(); track item) {
@if (editing()) {
<input
@ -204,14 +201,6 @@ import {
}
}
.empty-hint {
@include medium-text();
width: 100%;
margin: 0;
color: rgba($text-color, 0.72);
text-align: left;
}
input[type='text'] {
@include sub-title-text();
width: 100%;
@ -384,7 +373,6 @@ export class SelectAddComponent implements OnChanges {
readonly selected = input<string | null>(null);
readonly editable = input<boolean>(false);
readonly placeholder = input<string>('Select…');
readonly emptyHint = input<string>('');
readonly alwaysDropShadow = input<boolean>(false);
readonly onlyShadowBorder = input<boolean>(false);
@ -419,7 +407,7 @@ export class SelectAddComponent implements OnChanges {
protected resolvedSelected(): string | null {
// New API: string
const s = this.selected();
if (s != null) return s;
if (s != null && s.trim()) return s;
// Legacy API: index into options
const idx = this.selectedIndex();
const opts = this.resolvedItems();

View file

@ -21,24 +21,100 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
import { toCss } from '../../utils/color';
/** Tracks which entry path the block-edit modal was opened from. */
type EditEntry = { filter: 'done' | 'pending'; activeId: string | null };
interface EditEntry {
filter: 'done' | 'pending';
activeId: string | null;
}
/** A done block augmented with per-render animation state. */
interface StyledBlock extends Block {
export interface StyledBlock extends Block {
_anim: '' | 'descend' | 'ascend';
_transform: 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,
): { 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));
const usedSquares = totalSquares(shownRestingBlocks);
const remainingBudget = Math.max(0, normalizedLimit - usedSquares);
const transitioningBlocks =
remainingBudget > 0
? fitNewestBySquares(
styled.filter((b) => b._opacity !== '1'),
remainingBudget,
)
: [];
const shownIds = new Set([
...shownRestingBlocks.map((b) => b.id),
...transitioningBlocks.map((b) => b.id),
]);
return {
hiddenCount,
visibleStyled: styled.filter((b) => shownIds.has(b.id)),
};
}
@Component({
selector: 'lt-tower',
standalone: true,
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tower">
<div #towerRoot class="tower">
<div class="tower-header">
<input
#nameInput
@ -76,26 +152,31 @@ const BLOCKS_PER_ROW = 6;
<div
#stackZone
class="stack-zone"
[class.empty]="tower().blocks.length === 0"
[style.--block-stack-height]="blockStackHeight()"
>
<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">
@for (b of visibleBlocks(); track b.id) {
@for (sq of squares(); track sq.key) {
<lt-block
[block]="b"
[block]="sq.block"
[baseColor]="tower().base_color"
[class]="b._anim"
[style.transform]="b._transform"
[style.opacity]="b._opacity"
(clicked)="onEditBlock(b)"
[class]="sq.block._anim"
[style.transform]="sq.block._transform"
[style.opacity]="sq.block._opacity"
(clicked)="onEditBlock(sq.block)"
/>
}
</div>
@ -209,6 +290,7 @@ const BLOCKS_PER_ROW = 6;
--block-stack-height: 0px;
--add-block-size: 48px;
--add-block-clearance: var(--medium-padding);
--empty-add-block-center-offset: 0px;
@include card();
overflow: hidden;
@ -297,6 +379,16 @@ const BLOCKS_PER_ROW = 6;
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;
@ -431,16 +523,18 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
readonly dateRange = input<{ from: number; to: number } | null>(null);
/** When true, the tasks accordion starts expanded on load. */
readonly keepTasksOpen = input<boolean>(false);
/** When true, completed blocks descend on this tower's first measured render. */
readonly animateInitialStack = input<boolean>(false);
// ── Outputs ────────────────────────────────────────────────────────────────
readonly updateTower = output<TowerSettingsResult>();
readonly deleteTowerRequest = output<void>();
/** 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. */
readonly saveBlock = output<{
blockId: string;
result: { tag: string; description: string; is_done: boolean };
result: { tag: string; description: string; is_done: boolean; difficulty: number };
}>();
readonly deleteBlock = output<string>();
@ -452,12 +546,13 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
readonly hiddenBlockCount = signal(0);
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;
// ── Derived ────────────────────────────────────────────────────────────────
/** 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. */
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
@ -467,7 +562,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
const entry = this.editEntry();
if (!entry) return [];
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(() => {
@ -492,8 +587,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
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.visibleBlocks().length / BLOCKS_PER_ROW);
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`;
});
@ -505,10 +615,11 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
effect(() => {
const range = this.dateRange();
const maxVisibleBlocks = this.maxVisibleBlocks();
const animateInitialStack = this.animateInitialStack();
// 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);
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks));
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks, animateInitialStack));
});
}
@ -545,6 +656,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
const styles = getComputedStyle(stackZone);
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
this.measureEmptyAddBlockOffset(stackZone);
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
const clearance = this.parseCssPixels(
styles.getPropertyValue('--add-block-clearance'),
@ -557,6 +669,18 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
}
private measureEmptyAddBlockOffset(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('--empty-add-block-center-offset', `${offset}px`);
}
private parseCssPixels(value: string, fallback: number): number {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback;
@ -566,16 +690,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
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);
return;
}
const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds;
const prevSet = new Set(prev);
const newIds = ids.filter(id => !prevSet.has(id));
const newIds = ids.filter((id) => !prevSet.has(id));
const grewByOne =
!this.isFirstRun &&
ids.length === prev.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);
@ -605,47 +736,37 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
});
}
const newInRangeId = grewByOne ? newIds[0] : null;
const visibleLimit =
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
const restingBlocks = styled.filter((b) => b._opacity === '1');
const hiddenCount = Number.isFinite(visibleLimit)
? Math.max(0, restingBlocks.length - visibleLimit)
: 0;
let visibleStyled = styled;
let hiddenCount = 0;
if (Number.isFinite(visibleLimit)) {
const shownRestingBlocks =
hiddenCount > 0 ? restingBlocks.slice(hiddenCount) : restingBlocks;
const remainingSlots = Math.max(0, visibleLimit - shownRestingBlocks.length);
const transitioningBlocks =
remainingSlots > 0
? styled.filter((b) => b._opacity !== '1').slice(-remainingSlots)
: [];
const shownIds = new Set([
...shownRestingBlocks.map((b) => b.id),
...transitioningBlocks.map((b) => b.id),
]);
visibleStyled = styled.filter((b) => shownIds.has(b.id));
({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
styled,
visibleLimit,
newInRangeId,
));
}
this.hiddenBlockCount.set(hiddenCount);
if (this.isFirstRun && animateInitialStack) {
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b));
if (initialBlocks.length > 0) {
this.startDescendAnimation(visibleStyled, initialBlocks);
this.prevDoneIds = ids;
this.isFirstRun = false;
return;
}
}
if (grewByOne) {
const newId = newIds[0];
const newBlock = visibleStyled.find(b => b.id === newId);
const newBlock = visibleStyled.find((b) => b.id === newId);
if (newBlock && inRange(newBlock)) {
// 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.
newBlock._anim = '';
newBlock._transform = 'translateY(500%)';
newBlock._opacity = '0';
this._visibleBlocks.set(visibleStyled);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
newBlock._anim = 'descend';
newBlock._transform = 'translateY(0)';
newBlock._opacity = '1';
this._visibleBlocks.set([...this._visibleBlocks()]);
});
});
this.startDescendAnimation(visibleStyled, [newBlock]);
this.prevDoneIds = ids;
this.isFirstRun = false;
return;
@ -658,6 +779,25 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
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);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const block of blocks) {
block._anim = 'descend';
block._transform = 'translateY(0)';
block._opacity = '1';
}
this._visibleBlocks.set([...this._visibleBlocks()]);
});
});
}
// ── Event handlers ─────────────────────────────────────────────────────────
onRename(event: Event): void {
@ -676,7 +816,12 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
onMarkTaskDone(block: Block): void {
this.saveBlock.emit({
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,
},
});
}
@ -691,11 +836,21 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
onBlockSave(ev: BlockEditSave): void {
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 {
this.saveBlock.emit({
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,
},
});
}
}

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import type { StyledBlock } from './tower.component';
import { 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('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']);
});
});

View file

@ -1,31 +1,106 @@
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
@Component({
selector: 'lt-welcome',
standalone: true,
imports: [],
imports: [A11yModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="welcome-card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2 id="welcome-title" tabindex="-1" cdkFocusInitial>Welcome to Life Towers</h2>
<h2>Welcome to Life Towers</h2>
<button
class="exit"
type="button"
(click)="close.emit()"
aria-label="Dismiss welcome"
></button>
<p class="lead">
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project).
Each <strong>tower</strong> is a stack of related tasks. As you finish a task, it falls into the
tower as a colored square the more you do, the taller it grows.
<p class="lead" id="welcome-description">
Life Towers turns completed tasks into visible stacks. Create pages for work, hobbies,
home, or any project. Add towers for task groups, then check off tasks to build them
block by block.
</p>
<p class="muted">
Everything you write is saved to a small remote database, keyed to a private UUID token shown
under <em>Settings Account</em>. Copy that token to recover your data on another device, or
paste a friend's token to look at theirs.
<p class="sr-only">
Preview showing three towers with pending task bars at the top and completed task
blocks stacked below.
</p>
<div class="tower-preview" aria-hidden="true">
<div class="preview-shell">
<div class="preview-page-tab"></div>
<div class="preview-towers">
<div class="preview-tower preview-tower--reading">
<span class="preview-task"></span>
<div class="preview-stack">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="preview-tower preview-tower--projects">
<span class="preview-task"></span>
<span class="preview-task preview-task--short"></span>
<div class="preview-stack">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="preview-tower preview-tower--exercise">
<span class="preview-task"></span>
<div class="preview-stack">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
</div>
<h3 class="sr-only" id="welcome-basics-title">How Life Towers works</h3>
<dl class="basics" aria-labelledby="welcome-basics-title">
<div class="basic">
<dt class="basic__label">Pages</dt>
<dd class="basic__text">Keep each area separate.</dd>
</div>
<div class="basic">
<dt class="basic__label">Towers</dt>
<dd class="basic__text">Stack related tasks together.</dd>
</div>
<div class="basic">
<dt class="basic__label">Blocks</dt>
<dd class="basic__text">Finished tasks become colored blocks.</dd>
</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 fresh</button>
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button>
<button type="button" (click)="startFresh.emit()">Start empty</button>
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
</div>
</div>
`,
@ -34,12 +109,27 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
:host { display: block; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.welcome-card {
@include card();
width: 66vw;
max-width: 480px;
width: min(560px, calc(100vw - (2 * var(--large-padding))));
max-width: 560px;
max-height: calc(100svh - (2 * var(--medium-padding)));
overflow-y: auto;
@media (max-width: $mobile-width) {
width: 88vw;
width: min(88vw, calc(100vw - (2 * var(--medium-padding))));
max-width: 88vw;
padding: var(--medium-padding);
}
@ -48,8 +138,20 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
position: relative;
box-shadow: $shadow;
text-align: left;
font-family: $normal-font;
font-weight: 300;
font-size: var(--medium-font-size);
line-height: 1.45;
@include inner-spacing(var(--medium-padding));
h2,
h3,
p,
dl,
dd {
margin: 0;
}
.exit {
position: absolute;
top: var(--medium-padding);
@ -58,31 +160,231 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
}
h2 {
font-family: $title-font;
font-weight: 400;
font-size: var(--larger-font-size);
text-align: center;
margin-bottom: var(--medium-padding);
padding: 0 36px;
line-height: 1.25;
@media (max-width: $mobile-width) {
padding: 0 28px;
}
}
p.lead { color: $text-color; }
.lead {
color: $text-color;
font-family: inherit;
font-weight: inherit;
font-size: inherit;
line-height: inherit;
max-width: 46ch;
margin-inline: auto;
text-align: center;
}
p.muted {
color: rgba($text-color, 0.7);
font-size: var(--medium-font-size);
em { font-style: italic; }
.tower-preview {
box-sizing: border-box;
padding: var(--medium-padding) 0;
border-top: 1px solid rgba($text-color, 0.08);
border-bottom: 1px solid rgba($text-color, 0.08);
@media (max-width: $mobile-width) {
padding-block: var(--small-padding);
}
}
.preview-shell {
width: min(100%, 370px);
margin: 0 auto;
}
.preview-page-tab {
width: 96px;
height: 14px;
margin: 0 auto var(--small-padding);
border-radius: var(--border-radius);
background: rgba($text-color, 0.08);
box-shadow: $shadow-border;
}
.preview-towers {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
gap: var(--small-padding);
}
.preview-tower {
display: flex;
flex-direction: column;
min-height: 128px;
padding: 6px;
box-sizing: border-box;
border-radius: var(--border-radius);
background: rgba($text-color, 0.025);
box-shadow: $shadow-border;
}
.preview-task {
display: block;
width: 74%;
height: 5px;
margin-bottom: 4px;
border-radius: var(--border-radius);
background: var(--task-color);
}
.preview-task--short {
width: 48%;
}
.preview-stack {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: end;
gap: 2px;
min-height: 86px;
margin-top: auto;
span {
display: block;
aspect-ratio: 1;
border-radius: 2px;
background: var(--block-color);
box-shadow: $shadow-border;
}
span:nth-child(2n) {
filter: saturate(0.9) brightness(1.06);
}
span:nth-child(3n) {
filter: saturate(1.08) brightness(0.96);
}
}
@media (prefers-reduced-motion: no-preference) {
@keyframes preview-task-pulse {
0%, 100% {
opacity: 0.42;
transform: scaleX(0.86);
}
45%, 70% {
opacity: 1;
transform: scaleX(1);
}
}
@keyframes preview-block-fall {
0% {
opacity: 0;
transform: translateY(-220%);
}
60%, 100% {
opacity: 1;
transform: translateY(0);
}
}
.preview-task {
transform-origin: left center;
animation: preview-task-pulse 2600ms ease-in-out infinite;
}
.preview-stack span {
opacity: 0;
animation: preview-block-fall 1200ms cubic-bezier(0.5, 0, 1, 0) forwards;
animation-delay: calc(160ms + var(--fall-index, 0) * 95ms);
}
.preview-stack span:nth-child(1) { --fall-index: 0; }
.preview-stack span:nth-child(2) { --fall-index: 1; }
.preview-stack span:nth-child(3) { --fall-index: 2; }
.preview-stack span:nth-child(4) { --fall-index: 3; }
.preview-stack span:nth-child(5) { --fall-index: 4; }
.preview-stack span:nth-child(6) { --fall-index: 5; }
.preview-stack span:nth-child(7) { --fall-index: 6; }
.preview-stack span:nth-child(8) { --fall-index: 7; }
.preview-stack span:nth-child(9) { --fall-index: 8; }
}
.preview-tower--reading {
--block-color: hsl(18, 70%, 58%);
--task-color: hsla(18, 70%, 58%, 0.26);
}
.preview-tower--projects {
--block-color: hsl(209, 65%, 52%);
--task-color: hsla(209, 65%, 52%, 0.24);
}
.preview-tower--exercise {
--block-color: hsl(130, 45%, 44%);
--task-color: hsla(130, 45%, 44%, 0.22);
}
.basics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--small-padding);
@media (max-width: $mobile-width) {
grid-template-columns: 1fr;
}
}
.basic {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.basic__label {
font-family: inherit;
font-weight: inherit;
color: $text-color;
font-size: inherit;
line-height: inherit;
}
.basic__text,
.account-note,
.choice-note {
font-family: inherit;
font-weight: inherit;
color: rgba($text-color, 0.82);
font-size: inherit;
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;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--large-padding);
margin-top: var(--large-padding);
button {
font-family: inherit;
font-weight: inherit;
font-size: inherit;
margin: 0;
max-width: 100%;
line-height: inherit;
text-align: center;
}
button.primary {