diff --git a/src/wardrobe/calendar.service.ts b/src/wardrobe/calendar.service.ts index 579049a3..fcd365b9 100644 --- a/src/wardrobe/calendar.service.ts +++ b/src/wardrobe/calendar.service.ts @@ -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> { + 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(); + 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 diff --git a/src/wardrobe/outfit.controller.ts b/src/wardrobe/outfit.controller.ts index be6aa384..56def4de 100644 --- a/src/wardrobe/outfit.controller.ts +++ b/src/wardrobe/outfit.controller.ts @@ -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') @@ -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( diff --git a/src/wardrobe/suggest-outfit.spec.ts b/src/wardrobe/suggest-outfit.spec.ts new file mode 100644 index 00000000..c7192631 --- /dev/null +++ b/src/wardrobe/suggest-outfit.spec.ts @@ -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([]); + }); +}); diff --git a/src/wardrobe/suggest-outfit.ts b/src/wardrobe/suggest-outfit.ts new file mode 100644 index 00000000..f018891c --- /dev/null +++ b/src/wardrobe/suggest-outfit.ts @@ -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, + 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, + rand: () => number = Math.random, +): Garment[] { + const of = (c: GarmentCategory) => garments.filter((g) => g.category === c); + 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, + ); +} diff --git a/views/outfits/index.hbs b/views/outfits/index.hbs index 1c8112dd..6f30194f 100644 --- a/views/outfits/index.hbs +++ b/views/outfits/index.hbs @@ -3,9 +3,12 @@
{{#if outfits.length}} diff --git a/views/outfits/suggest.hbs b/views/outfits/suggest.hbs new file mode 100644 index 00000000..1fd399f1 --- /dev/null +++ b/views/outfits/suggest.hbs @@ -0,0 +1,47 @@ +{{>navbar}} + +{{!-- ponytail: English strings hardcoded; move to lang.json (all locales) if this ships upstream. --}} +
+
+

Today's pick

+ Shuffle +
+ + {{#if garments.length}} +
+ {{#each garments}} +
+
+ {{#if this.photo}} + {{this.name}} + {{else}} +
+ {{/if}} +

{{this.name}}

+
+
+ {{/each}} +
+ +
+ {{#each garments}} + + + {{/each}} + + {{!-- Local date, not UTC: Jakarta mornings are still "yesterday" in UTC. --}} + + + +
+ {{else}} +
+

Add some tops and bottoms to your wardrobe first.

+ Wardrobe +
+ {{/if}} +
+ +{{>dock}}