Written by James Britt
A wardrobe tracker makes a useful Ruby project because its rules are easy to inspect. An item belongs to someone, has a category and may be available to wear. An outfit combines several items. A wear log records what actually happened.
The interesting work starts when those records disagree. A saved outfit contains shoes that are now in storage. A new jacket has no wear history. A recommendation includes something the user has only added to a wishlist.
This article builds an independent Ruby design around those cases. Styl10’s closet organizer app provides a product reference for separating owned items from things someone is considering. The implementation here starts with plain Ruby objects and adds Rails where persistence, uploads and a web interface become useful.
Start with a small Ruby data model
Keep ownership status separate from availability. A garment can be owned but unavailable because it is in the wash. A wishlist item may have a photograph and detailed tags, yet it should never appear in an outfit suggested for today.
For an initial version, give each garment an integer ID, a category, an ownership status, an availability flag, season tags and an optional last-worn date. Use symbols consistently in the plain Ruby layer: :top, :owned and :spring, for example.
A Struct is enough to hold these values while the rules are being developed. It does not provide validation by itself. Validate incoming data at the boundary, including unknown categories, duplicate IDs and dates that have not been parsed successfully.
Season tags should be a collection. A scarf can be perfect for a cool spring evening while a heavy parka stays in storage. Giving an item both :winter and :spring tags expresses that overlap without a special case buried in the recommendation method.
Rank available garments with plain Ruby
Begin with a rule the developer can explain: exclude unavailable and unowned items, then give more weight to garments that have not been logged recently.
Here is a small implementation:
require "date"
Garment = Struct.new(
:id, :category, :status, :available, :seasons, :last_worn_on,
keyword_init: true
)
def rank_garments(garments, season:, today:)
garments.select do |garment|
garment.status == :owned &&
garment.available &&
garment.seasons.include?(season)
end.sort_by do |garment|
days = if garment.last_worn_on
[(today - garment.last_worn_on).to_i, 0].max
else
30
end
[-[days, 90].min, garment.id]
end
end
The method expects today and any last_worn_on value to be Ruby Date objects, seasons to be an array of symbols, and garment IDs to be unique integers. Pass only garments the caller is allowed to access.
The first block filters the collection. The second sorts it by a score and then by ID. Negating the score puts larger gaps first; the ID provides a repeatable order when scores tie.
Two numbers deserve explanation. An item with no recorded wear receives 30 points. An older logged item receives at most 90. Those are example product choices, not statistics about clothing habits. A missing date means the application has no observation, not that the garment has never been worn.
The method also clamps a future date to zero points. Input validation should still reject unintended future wear entries. That defensive calculation keeps one bad record from receiving a negative recency value.
Because the date is passed in, a test can use a fixed day. There is no hidden call to Date.today that changes the expected result tomorrow.
Turn ranked items into candidate outfits
Ranking individual garments does not yet produce an outfit. The application needs a template describing the required categories.
For a first prototype, use one top, one bottom and one footwear item:
ranked = rank_garments(garments, season: :spring, today: today)
groups = ranked.group_by(&:category)
tops = groups.fetch(:top, []).first(10)
bottoms = groups.fetch(:bottom, []).first(10)
shoes = groups.fetch(:footwear, []).first(10)
candidates = tops.product(bottoms, shoes)
Ruby’s group_by separates the ranked items by category. Array#product then creates the combinations. If any required category is empty, the result is empty rather than an incomplete outfit.
The shortlist keeps this example to at most 1,000 candidates: ten choices in each of three categories. Without a bound, the number of combinations grows quickly. Keep the limit explicit and check whether it discards combinations users would have preferred.
These are candidates, not finished styling recommendations. The code has not evaluated colour, fit, formality or weather. Add those rules as separate Ruby objects or methods, with tests describing what each rule accepts.
A second template might combine a dress and footwear. Do not force a dress into the :top category merely to make the first algorithm work.
Record wear events separately from suggestions
Generating an outfit must not update its garments’ last-worn dates. Neither should saving an outfit for later.
Only an explicit action such as “Wore this” should create a wear event. Store the selected garment IDs and the date of that event, so editing a saved outfit later does not rewrite the historical record.
In a Rails version, possible models are Garment, Outfit, WearEvent and join records connecting garments to outfits and events. These are proposed application models, not Rails features.
Write the event and its garment associations in a transaction. A repeated submission should not create duplicate events: use an operation identifier scoped to the user and enforce its uniqueness in the database.
The ranking code can consume a derived last_worn_on value calculated from events. If the application caches that value, define how edits and deletions of wear events refresh it. Otherwise, removing an accidental entry may leave the recommendations using stale information.
Calculate wardrobe reports from the user’s records
For Ruby reporting, a sentence beginning “Most people” is a reason to inspect the evidence before turning it into a constant. The linked 80/20 essay discusses the balance between everyday basics and statement pieces. It does not establish a universal wear-frequency ratio.
Build a report from the account’s own events instead.
A rotation report might show how many eligible owned garments have at least one logged wear during a selected period. Define that denominator: archived items, wishlist entries and newly added garments can distort the result if they are mixed together without explanation.
Handle an empty wardrobe explicitly rather than dividing by zero. Label the output “logged wear” so missing entries are not presented as proof that an item went unused.
For a small Ruby prototype, grouped arrays are sufficient. Once events live in PostgreSQL, use Active Record aggregation to avoid loading the entire history for every report. Scope the query to the current account before grouping or counting.
Add Rails around the recommendation code
The ranker should remain callable without a controller, a browser session or a network request. Rails can load the records, convert them into the expected Ruby values and pass them to the method.
That conversion matters. Database-backed status values may arrive as strings, while this example compares symbols. Keep a deliberate mapping at the boundary instead of mixing representations throughout the code.
Use Active Storage for garment photographs if the Rails application needs uploads. Store the attachment relationship with the garment and configure the storage service separately. Validate uploads, restrict access to private images and decide how deleting an account removes its associated files.
Image classification can be added later through a background job. Save the garment first, display a processing state and let the user correct suggested categories. If a job finishes after a manual correction, it should not silently overwrite that choice.
A photo upload, a categorisation result and an accepted recommendation are separate events. Keeping them separate makes the Ruby code easier to reason about when processing fails or runs twice.
Introduce weather through an adapter
Do not put a live weather request inside rank_garments. Give a small Ruby adapter responsibility for fetching the forecast and translating it into application inputs.
The caller can then apply a temperature or rain rule before composing outfits. Tests can supply a fixed forecast without contacting an external service.
Specify the temperature unit, forecast time and behaviour when the provider is unavailable. A cached forecast should carry its observation or retrieval time. Yesterday’s forecast should not quietly pass as a fresh result.
The same approach works for optional calendar data. Pass a simple occasion value into the recommendation layer rather than teaching the ranker how to authenticate with a calendar provider.
Test the Ruby behaviour users will notice
Minitest or RSpec can cover the ranking method without booting Rails. Useful cases include a wishlist item that must be excluded, an unavailable favourite, a missing date, tied scores and an empty category.
Add tests around the history too. Generating a suggestion must leave wear events unchanged. Repeating the same “Wore this” submission must not double-count it. Correcting an event date must affect the next report.
For the Rails interface, verify that changing a garment ID in a request cannot expose or modify another account’s wardrobe. Hiding a button is not an authorization check.
The first useful release needs an inventory, an accurate wear log and recommendations whose rules can be explained. Build those in Ruby, inspect the results with real user feedback, and extend the scoring only when a specific failure gives the team a reason to do so.
