Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions scripts/scale-seed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const Database = require('better-sqlite3');
const { randomUUID } = require('crypto');

const dbPath = process.argv[2];
const n = parseInt(process.argv[3], 10);
if (!dbPath || !n) {
console.error('usage: node seed.js <dbPath> <count>');
process.exit(1);
}

const categories = ['accessories', 'bags', 'outerwear', 'dresses', 'tops', 'bottoms', 'footwear', 'other'];
const colors = ['red', 'pink', 'orange', 'yellow', 'green', 'blue', 'purple', 'black', 'white', 'grey', 'beige', 'brown', 'gold', 'silver', 'pattern', 'other'];
const adjectives = ['Blue', 'Vintage', 'Cotton', 'Linen', 'Striped', 'Oversized', 'Slim-fit', 'Wool', 'Denim', 'Floral', 'Plaid', 'Cropped'];
const nouns = ['Sweater', 'Jeans', 'Jacket', 'T-Shirt', 'Dress', 'Skirt', 'Sneakers', 'Boots', 'Scarf', 'Hat', 'Coat', 'Shorts'];

const db = new Database(dbPath);
db.pragma('journal_mode = WAL');

const insertGarment = db.prepare(`
INSERT INTO garment (shareable_id, flagged, banned, name, category, color, brand, size, notes, photo_id, owner_id, date_aquired, washing_details, archived)
VALUES (@shareableId, 0, 0, @name, @category, @color, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0)
`);

const insertMany = db.transaction((count) => {
for (let i = 1; i <= count; i++) {
const adj = adjectives[i % adjectives.length];
const noun = nouns[i % nouns.length];
insertGarment.run({
shareableId: randomUUID(),
name: `${adj} ${noun} #${i}`,
category: categories[i % categories.length],
color: colors[i % colors.length],
});
}
});

insertMany(n);

// Seed a few drawers to exercise the drawers UX at the same scale.
const insertDrawer = db.prepare(`INSERT INTO drawer (name, notes, owner_id) VALUES (?, ?, NULL)`);
const insertDrawerGarment = db.prepare(`INSERT INTO drawer_garments (drawer_id, garment_id) VALUES (?, ?)`);
const allIds = db.prepare('SELECT id FROM garment').all().map((r) => r.id);

if (allIds.length > 0) {
// One small drawer (~5 items, or fewer if the catalog is tiny)
const smallId = insertDrawer.run('Winter Coats', 'Cold weather outerwear').lastInsertRowid;
allIds.slice(0, Math.min(5, allIds.length)).forEach((gid) => insertDrawerGarment.run(smallId, gid));

// One drawer sized ~10% of the catalog (capped) to see how a "big" drawer renders
const bigCount = Math.min(Math.max(Math.floor(allIds.length * 0.1), 1), 200);
const bigId = insertDrawer.run('Everything Casual', 'A deliberately large drawer to stress-test the UI').lastInsertRowid;
allIds.slice(0, bigCount).forEach((gid) => insertDrawerGarment.run(bigId, gid));
}

db.pragma('wal_checkpoint(TRUNCATE)');
console.log(`Seeded ${n} garments and drawers into ${dbPath}`);
34 changes: 34 additions & 0 deletions scripts/scale-shots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const { chromium } = require('playwright');

const viewports = {
mobile: { width: 390, height: 844 },
desktop: { width: 1440, height: 900 },
};

const pages = [
{ name: 'wardrobe', path: '/wardrobe' },
{ name: 'drawers-index', path: '/drawers' },
{ name: 'drawer-new-picker', path: '/drawers/new' },
];

async function main() {
const n = process.argv[2];
const browser = await chromium.launch();
for (const [vpName, vp] of Object.entries(viewports)) {
const context = await browser.newContext({ viewport: vp });
const page = await context.newPage();
for (const p of pages) {
const start = Date.now();
await page.goto(`http://localhost:3000${p.path}`, { waitUntil: 'load', timeout: 60000 });
const loadMs = Date.now() - start;
const domNodes = await page.evaluate(() => document.querySelectorAll('*').length);
const imgCount = await page.evaluate(() => document.querySelectorAll('img').length);
console.log(`n=${n} ${vpName} ${p.name}: load=${loadMs}ms domNodes=${domNodes} imgTags=${imgCount}`);
await page.screenshot({ path: `/tmp/scale-test/screenshots/${p.name}-${vpName}-n${n}.png`, fullPage: p.name !== 'wardrobe' });
}
await context.close();
}
await browser.close();
}

main().catch((e) => { console.error(e); process.exit(1); });
74 changes: 74 additions & 0 deletions scripts/verify-lib10.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const { chromium } = require('playwright');
const fs = require('fs');

const viewports = {
mobile: { width: 390, height: 844 },
desktop: { width: 1440, height: 900 },
};

async function main() {
fs.mkdirSync('/tmp/lib10-shots', { recursive: true });
const browser = await chromium.launch();

for (const [vpName, vp] of Object.entries(viewports)) {
const context = await browser.newContext({ viewport: vp });
const page = await context.newPage();

await page.goto('http://localhost:3000/drawers/new', { waitUntil: 'load' });
const domNodes = await page.evaluate(() => document.querySelectorAll('*').length);
const cardCount = await page.locator('.garment-card').count();
const searchBox = await page.locator('#picker-search').boundingBox();
const categoryBox = await page.locator('#picker-category').boundingBox();
console.log(
`[${vpName}] /drawers/new domNodes=${domNodes} renderedCards=${cardCount} searchInputWidth=${searchBox.width}px categorySelectWidth=${categoryBox.width}px`,
);
await page.screenshot({ path: `/tmp/lib10-shots/picker-initial-${vpName}.png` });

// type into search, expect an htmx-driven server round trip that shrinks the grid
await page.fill('#picker-search', 'Denim');
await page.waitForTimeout(500); // debounce + request
const cardsAfterSearch = await page.locator('.garment-card').count();
const searchBoxAfterType = await page.locator('#picker-search').boundingBox();
console.log(
`[${vpName}] AFTER search "Denim": renderedCards=${cardsAfterSearch} searchInputWidthWhileTyping=${searchBoxAfterType.width}px`,
);
await page.screenshot({ path: `/tmp/lib10-shots/picker-after-search-${vpName}.png` });

// select a garment, then change the search so it's no longer in the grid
await page.locator('.picker-checkbox').first().evaluate((el) => {
el.checked = true;
el.dispatchEvent(new Event('change', { bubbles: true }));
});
const selectedCountAfterCheck = await page.locator('#selected-inputs .selected-hidden').count();
await page.fill('#picker-search', 'zzzznomatch');
await page.waitForTimeout(500);
const emptyVisible = await page.locator('text=' + (await page.locator('#garment-picker-grid p').textContent().catch(() => '')));
const cardsAfterNoMatch = await page.locator('.garment-card').count();
const selectedCountAfterFilter = await page.locator('#selected-inputs .selected-hidden').count();
console.log(
`[${vpName}] selected after check=${selectedCountAfterCheck}, cards after no-match search=${cardsAfterNoMatch}, selected still tracked=${selectedCountAfterFilter}`,
);

// category filter
await page.fill('#picker-search', '');
await page.selectOption('#picker-category', 'outerwear');
await page.waitForTimeout(500);
const cardsAfterCategory = await page.locator('.garment-card').count();
console.log(`[${vpName}] AFTER category=outerwear: renderedCards=${cardsAfterCategory}`);
await page.screenshot({ path: `/tmp/lib10-shots/picker-after-category-${vpName}.png` });

// lazy loading check
const lazyImgCount = await page.locator('.garment-card img[loading="lazy"]').count();
const totalImgCount = await page.locator('.garment-card img').count();
console.log(`[${vpName}] lazy-loaded images: ${lazyImgCount}/${totalImgCount}`);

await context.close();
}

await browser.close();
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
81 changes: 81 additions & 0 deletions scripts/verify-lib8.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const { chromium } = require('playwright');
const fs = require('fs');

const viewports = {
mobile: { width: 390, height: 844 },
desktop: { width: 1440, height: 900 },
};

async function main() {
fs.mkdirSync('/tmp/lib8-shots', { recursive: true });
const browser = await chromium.launch();

for (const [vpName, vp] of Object.entries(viewports)) {
const context = await browser.newContext({ viewport: vp });
const page = await context.newPage();

// /wardrobe pagination
await page.goto('http://localhost:3000/wardrobe', { waitUntil: 'load' });
const wardrobeDom = await page.evaluate(() => document.querySelectorAll('*').length);
console.log(`[${vpName}] /wardrobe page1 domNodes=${wardrobeDom}`);
await page.screenshot({ path: `/tmp/lib8-shots/wardrobe-${vpName}.png` });

// /drawers index (thumbnail cap)
await page.goto('http://localhost:3000/drawers', { waitUntil: 'load' });
const drawersDom = await page.evaluate(() => document.querySelectorAll('*').length);
console.log(`[${vpName}] /drawers index domNodes=${drawersDom}`);
await page.screenshot({ path: `/tmp/lib8-shots/drawers-index-${vpName}.png`, fullPage: true });

// /drawers/2 (100-item drawer) pagination
await page.goto('http://localhost:3000/drawers/2', { waitUntil: 'load' });
const drawerShowDom = await page.evaluate(() => document.querySelectorAll('*').length);
console.log(`[${vpName}] /drawers/2 domNodes=${drawerShowDom}`);
await page.screenshot({ path: `/tmp/lib8-shots/drawer-show-${vpName}.png`, fullPage: true });

// /drawers/new picker search
await page.goto('http://localhost:3000/drawers/new', { waitUntil: 'load' });
const pickerDomBefore = await page.evaluate(() => document.querySelectorAll('*').length);
const cardsBefore = await page.locator('.garment-card:not(.hidden)').count();
console.log(`[${vpName}] /drawers/new BEFORE search: domNodes=${pickerDomBefore} visibleCards=${cardsBefore}`);
await page.screenshot({ path: `/tmp/lib8-shots/picker-before-${vpName}.png` });

await page.fill('#picker-search', 'Denim');
await page.waitForTimeout(400); // debounce
const cardsAfterSearch = await page.locator('.garment-card:not(.hidden)').count();
console.log(`[${vpName}] /drawers/new AFTER search "Denim": visibleCards=${cardsAfterSearch}`);
await page.screenshot({ path: `/tmp/lib8-shots/picker-after-search-${vpName}.png` });

await page.fill('#picker-search', 'zzzznomatch');
await page.waitForTimeout(400);
const emptyVisible = await page.locator('#picker-empty').isVisible();
const cardsAfterNoMatch = await page.locator('.garment-card:not(.hidden)').count();
console.log(`[${vpName}] /drawers/new AFTER no-match search: visibleCards=${cardsAfterNoMatch} emptyMessageVisible=${emptyVisible}`);

await page.fill('#picker-search', '');
await page.selectOption('#picker-category', 'outerwear');
await page.waitForTimeout(400);
const cardsAfterCategory = await page.locator('.garment-card:not(.hidden)').count();
console.log(`[${vpName}] /drawers/new AFTER category=outerwear: visibleCards=${cardsAfterCategory}`);
await page.screenshot({ path: `/tmp/lib8-shots/picker-after-category-${vpName}.png` });

// checkbox selection persists through a filter change
await page.selectOption('#picker-category', '');
await page.waitForTimeout(200);
await page.locator('.garment-card input[type=checkbox]').first().evaluate((el) => {
el.checked = true;
});
await page.fill('#picker-search', 'Denim');
await page.waitForTimeout(400);
const checkedCount = await page.locator('.garment-card input[type=checkbox]:checked').count();
console.log(`[${vpName}] /drawers/new checked garments still checked after filtering: ${checkedCount}`);

await context.close();
}

await browser.close();
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
34 changes: 34 additions & 0 deletions src/dal/entity/drawer.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
Collection,
Entity,
ManyToMany,
ManyToOne,
PrimaryKey,
Property,
type Ref,
} from '@mikro-orm/core';
import { Garment } from './garment.entity';
import { User } from './user.entity';

@Entity()
export class Drawer {
@PrimaryKey()
public id!: number;

@Property()
public name!: string;

@Property({ nullable: true })
public notes?: string;

@ManyToMany(() => Garment, (garment) => garment.drawers, { owner: true })
public garments = new Collection<Garment>(this);

@ManyToOne({
entity: () => User,
deleteRule: 'cascade',
ref: true,
nullable: true,
})
public owner?: Ref<User>;
}
4 changes: 4 additions & 0 deletions src/dal/entity/garment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Property,
type Ref,
} from '@mikro-orm/core';
import { Drawer } from './drawer.entity';
import { File } from './file.entity';
import { Outfit } from './outfit.entity';
import { ShareableId } from './shareableId.entity';
Expand Down Expand Up @@ -64,4 +65,7 @@ export class Garment extends ShareableId {

@ManyToMany(() => Outfit, (outfit) => outfit.garments)
public outfits = new Collection<Outfit>(this);

@ManyToMany(() => Drawer, (drawer) => drawer.garments)
public drawers = new Collection<Drawer>(this);
}
26 changes: 26 additions & 0 deletions src/dal/migrations/postgres/Migration20260722221513.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Migration } from '@mikro-orm/migrations';

export class Migration20260722221513 extends Migration {

override async up(): Promise<void> {
this.addSql(`create table "drawer" ("id" serial primary key, "name" varchar(255) not null, "notes" varchar(255) null, "owner_id" int null);`);

this.addSql(`create table "drawer_garments" ("drawer_id" int not null, "garment_id" int not null, constraint "drawer_garments_pkey" primary key ("drawer_id", "garment_id"));`);

this.addSql(`alter table "drawer" add constraint "drawer_owner_id_foreign" foreign key ("owner_id") references "user" ("id") on update cascade on delete cascade;`);

this.addSql(`alter table "drawer_garments" add constraint "drawer_garments_drawer_id_foreign" foreign key ("drawer_id") references "drawer" ("id") on update cascade on delete cascade;`);
this.addSql(`alter table "drawer_garments" add constraint "drawer_garments_garment_id_foreign" foreign key ("garment_id") references "garment" ("id") on update cascade on delete cascade;`);
}

override async down(): Promise<void> {
this.addSql(`alter table "drawer_garments" drop constraint "drawer_garments_drawer_id_foreign";`);

this.addSql(`alter table "drawer_garments" drop constraint "drawer_garments_garment_id_foreign";`);

this.addSql(`drop table if exists "drawer" cascade;`);

this.addSql(`drop table if exists "drawer_garments" cascade;`);
}

}
Loading