Skip to content
Closed
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
24 changes: 24 additions & 0 deletions src/wardrobe/calendar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,30 @@ export class CalendarService {
);
}

/**
* Latest wear date per garment id. An entry counts as worn on `wornAt`, or on
* its planned date if never ticked, so forgetting to tap "worn" doesn't make
* the same outfit resurface. Entries planned up to a day ahead count too
* (don't suggest what's already scheduled for tomorrow).
*/
async lastWornByGarment(userId?: number): Promise<Map<number, Date>> {
const ownerFilter =
userId != null ? { owner: { id: userId } } : { owner: null };
const entries = await this.calendarRepository.find(
{ ...ownerFilter, date: { $lte: new Date(Date.now() + 864e5) } },
{ populate: ['outfit.garments'] },
);
const last = new Map<number, Date>();
for (const e of entries) {
const when = e.wornAt ?? e.date;
for (const garment of e.outfit.getEntity().garments.getItems()) {
const prev = last.get(garment.id);
if (!prev || when > prev) last.set(garment.id, when);
}
}
return last;
}

/**
* Assembles the full view-model for the calendar index page.
* Encapsulates all date math, month-navigation, and entry transformation
Expand Down
13 changes: 13 additions & 0 deletions src/wardrobe/outfit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Payload } from '../auth/dto/payload.dto';
import { OutfitService } from './outfit.service';
import { GarmentService } from './garment.service';
import { CalendarService } from './calendar.service';
import { suggestOutfit } from './suggest-outfit';

@UseGuards(ConditionalAuthGuard)
@Controller('outfits')
Expand All @@ -44,6 +45,18 @@ export class OutfitController {
return { outfits };
}

// Must stay above @Get(':id'), which would otherwise swallow "suggest".
@Get('suggest')
@Render('outfits/suggest')
async suggest(@Req() req: FastifyRequest) {
const userId = this.userId(req);
const [garments, lastWorn] = await Promise.all([
this.garmentService.findAll(userId),
this.calendarService.lastWornByGarment(userId),
]);
return { garments: suggestOutfit(garments, lastWorn) };
}

@Get('new')
@Render('outfits/form')
async newForm(
Expand Down
40 changes: 40 additions & 0 deletions src/wardrobe/suggest-outfit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Garment } from '../dal/entity/garment.entity';
import { suggestOutfit } from './suggest-outfit';

const g = (id: number, category: string) => ({ id, category }) as Garment;
const day = (n: number) => new Date(2026, 9, n);

describe('suggestOutfit', () => {
const closet = [
...[1, 2, 3, 4, 5].map((id) => g(id, 'tops')),
...[10, 11, 12, 13].map((id) => g(id, 'bottoms')),
g(20, 'footwear'),
];

it('returns top + bottom + footwear', () => {
const cats = suggestOutfit(closet, new Map()).map((x) => x.category);
expect(cats).toEqual(['tops', 'bottoms', 'footwear']);
});

it('never picks the most recently worn garments while staler ones exist', () => {
// tops 1-3 worn recently, 4-5 never worn -> pool is 4, 5, then stalest worn
const lastWorn = new Map([
[1, day(9)],
[2, day(10)],
[3, day(8)],
]);
for (let i = 0; i < 50; i++) {
const top = suggestOutfit(closet, lastWorn)[0];
expect([3, 4, 5]).toContain(top.id);
}
});

it('uses a dress when there are no tops or bottoms', () => {
const out = suggestOutfit([g(30, 'dresses')], new Map());
expect(out.map((x) => x.id)).toEqual([30]);
});

it('skips missing categories instead of failing', () => {
expect(suggestOutfit([], new Map())).toEqual([]);
});
});
48 changes: 48 additions & 0 deletions src/wardrobe/suggest-outfit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Garment } from '../dal/entity/garment.entity';
import { GarmentCategory } from './garment-category.enum';

// ponytail: naive heuristic, no color/formality/weather rules. Garments have
// no formality or warmth tag to rule on; add those fields first if needed.
const POOL = 3;

/**
* Picks one of the POOL least-recently-worn garments (never-worn first, ties
* broken randomly so "shuffle" keeps surfacing new items).
*/
function pick(
items: Garment[],
lastWorn: Map<number, Date>,
rand: () => number,
): Garment | undefined {
const stalest = items
.map((g) => ({
g,
worn: lastWorn.get(g.id)?.getTime() ?? 0,
tie: rand(),
}))
.sort((a, b) => a.worn - b.worn || a.tie - b.tie)
.slice(0, POOL);
return stalest[Math.floor(rand() * stalest.length)]?.g;
}

/** One dress, or top + bottom, plus footwear when the closet has any. */
export function suggestOutfit(
garments: Garment[],
lastWorn: Map<number, Date>,
rand: () => number = Math.random,
): Garment[] {
const of = (c: GarmentCategory) => garments.filter((g) => g.category === c);

Check failure on line 34 in src/wardrobe/suggest-outfit.ts

View workflow job for this annotation

GitHub Actions / back-end-ci

The two values in this comparison do not have a shared enum type
const dresses = of(GarmentCategory.DRESSES);
const tops = of(GarmentCategory.TOPS);
const bottoms = of(GarmentCategory.BOTTOMS);

const useDress =
dresses.length > 0 && (!tops.length || !bottoms.length || rand() < 0.5);
const body = useDress
? [pick(dresses, lastWorn, rand)]
: [pick(tops, lastWorn, rand), pick(bottoms, lastWorn, rand)];

return [...body, pick(of(GarmentCategory.FOOTWEAR), lastWorn, rand)].filter(
(g): g is Garment => g !== undefined,
);
}
9 changes: 6 additions & 3 deletions views/outfits/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
<main class="p-4 pt-20 pb-24">
<div class="flex items-center justify-between mb-6 px-2">
<h1 class="text-2xl font-bold">{{t 'lang.OUTFITS'}}</h1>
<a href="/outfits/new" class="btn btn-primary btn-sm"
>+ {{t 'lang.BUILD_OUTFIT'}}</a
>
<div class="flex gap-2">
<a href="/outfits/suggest" class="btn btn-secondary btn-sm">Suggest</a>
<a href="/outfits/new" class="btn btn-primary btn-sm"
>+ {{t 'lang.BUILD_OUTFIT'}}</a
>
</div>
</div>

{{#if outfits.length}}
Expand Down
47 changes: 47 additions & 0 deletions views/outfits/suggest.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{{>navbar}}

{{!-- ponytail: English strings hardcoded; move to lang.json (all locales) if this ships upstream. --}}
<main class="p-4 pt-20 pb-24">
<div class="flex items-center justify-between mb-6 px-2">
<h1 class="text-2xl font-bold">Today's pick</h1>
<a href="/outfits/suggest" class="btn btn-ghost btn-sm">Shuffle</a>
</div>

{{#if garments.length}}
<div class="flex flex-wrap gap-3 justify-center mb-6">
{{#each garments}}
<div class="card bg-base-100 shadow-sm w-40">
<div class="card-body p-2 items-center text-center">
{{#if this.photo}}
<img src="/file/nobg/{{this.photo.fileName}}" alt="{{this.name}}" class="size-32 rounded object-cover" />
{{else}}
<div class="size-32 rounded bg-base-200"></div>
{{/if}}
<p class="text-xs truncate w-full">{{this.name}}</p>
</div>
</div>
{{/each}}
</div>

<form method="post" action="/outfits" class="flex flex-col items-center gap-3">
{{#each garments}}
<input type="hidden" name="category" value="{{this.category}}" />
<input type="hidden" name="garmentId" value="{{this.id}}" />
{{/each}}
<input type="hidden" name="returnTo" value="/calendar" />
{{!-- Local date, not UTC: Jakarta mornings are still "yesterday" in UTC. --}}
<input type="date" name="scheduleDate" class="input input-bordered" required />
<script>
document.currentScript.previousElementSibling.value = new Date().toLocaleDateString('sv');
</script>
<button type="submit" class="btn btn-primary">Wear this</button>
</form>
{{else}}
<div class="flex flex-col items-center gap-4 pt-20 text-base-content/40">
<p>Add some tops and bottoms to your wardrobe first.</p>
<a href="/wardrobe" class="btn btn-primary btn-sm">Wardrobe</a>
</div>
{{/if}}
</main>

{{>dock}}
Loading