Skip to content

Commit 6749369

Browse files
feat: New system to show how well products match user preferences (#6764)
* feat: start of new system for personal restrictions and preferences #6714 * change score for NOVA 3 from 50 to 75 * new product match system #6714 * new product match system #6714 * new product match system #6714 * fix js lint issues * fix js issue * comment unused code * update tests * add option to turn on/off filter tabs * changes suggested through the code review * fix lint issues * Update html/js/product-search.js Co-authored-by: Alex Garel <[email protected]> * change spaces to tabs Co-authored-by: Alex Garel <[email protected]>
1 parent 7026da7 commit 6749369

File tree

7 files changed

+261
-84
lines changed

7 files changed

+261
-84
lines changed

html/js/product-search.js

Lines changed: 142 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@
88
//
99
// Output values are returned in the product object
1010
//
11-
// - match_status: yes, no, unknown
12-
// - match_score: number (maximum depends on the preferences)
11+
// - match_status:
12+
// very_good_match
13+
// good_match
14+
// poor_match
15+
// unknown_match
16+
// may_not_match
17+
// does_not_match
18+
//
19+
// - match_score: number from 0 to 100
20+
//
1321
// - match_attributes: array of arrays of attributes corresponding to the product and
1422
// each set of preferences: mandatory, very_important, important
1523

1624
function match_product_to_preferences (product, product_preferences) {
1725

1826
var score = 0;
19-
var status = "yes";
2027
var debug = "";
2128

2229
product.match_attributes = {
@@ -25,72 +32,132 @@ function match_product_to_preferences (product, product_preferences) {
2532
"important" : []
2633
};
2734

35+
// Note: mandatory preferences is set to 0:
36+
// The attribute is only used to check if a product is compatible or not
37+
// It does not affect the very good / good / poor match status
38+
// The score will be 0 if the product is not compatible
39+
var preferences_factors = {
40+
"mandatory" : 0,
41+
"very_important" : 2,
42+
"important" : 1,
43+
"not_important" : 0
44+
};
45+
46+
var sum_of_factors = 0;
47+
var sum_of_factors_for_unknown_attributes = 0;
48+
2849
if (product.attribute_groups) {
50+
51+
product.attributes_for_status = {};
2952

3053
// Iterate over attribute groups
3154
$.each( product.attribute_groups, function(key, attribute_group) {
3255

3356
// Iterate over attributes
3457

3558
$.each(attribute_group.attributes, function(key, attribute) {
59+
60+
var attribute_preference = product_preferences[attribute.id];
61+
var match_status_for_attribute = "match";
3662

37-
if ((! product_preferences[attribute.id]) || (product_preferences[attribute.id] == "not_important")) {
63+
if ((! attribute_preference) || (attribute_preference === "not_important")) {
3864
// Ignore attribute
3965
debug += attribute.id + " not_important" + "\n";
4066
}
4167
else {
68+
69+
var attribute_factor = preferences_factors[attribute_preference];
70+
sum_of_factors += attribute_factor;
4271

43-
if (attribute.status == "unknown") {
44-
45-
// If the attribute is important or more, then mark the product unknown
46-
// if the attribute is unknown (unless the product is already not matching)
47-
48-
if (status == "yes") {
49-
status = "unknown";
72+
if (attribute.status === "unknown") {
73+
74+
sum_of_factors_for_unknown_attributes += attribute_factor;
75+
76+
// If the attribute is mandatory and the attribute status is unknown
77+
// then mark the product status unknown
78+
79+
if (attribute_preference === "mandatory") {
80+
match_status_for_attribute = "unknown_match";
5081
}
5182
}
5283
else {
5384

54-
debug += attribute.id + " " + product_preferences[attribute.id] + " - match: " + attribute.match + "\n";
55-
56-
if (product_preferences[attribute.id] == "important") {
57-
58-
score += attribute.match;
59-
}
60-
else if (product_preferences[attribute.id] == "very_important") {
61-
62-
score += attribute.match * 2;
63-
}
64-
else if (product_preferences[attribute.id] == "mandatory") {
85+
debug += attribute.id + " " + attribute_preference + " - match: " + attribute.match + "\n";
6586

66-
score += attribute.match * 4;
67-
68-
if (attribute.match <= 20) {
69-
status = "no";
87+
score += attribute.match * attribute_factor;
88+
89+
if (attribute_preference === "mandatory") {
90+
if (attribute.match <= 10) {
91+
// Mandatory attribute with a very bad score (e.g. contains an allergen) -> status: does not match
92+
match_status_for_attribute = "does_not_match";
93+
}
94+
// Mandatory attribute with a bad score (e.g. may contain traces of an allergen) -> status: may not match
95+
else if (attribute.match <= 50) {
96+
match_status_for_attribute = "may_not_match";
7097
}
7198
}
7299
}
73-
74-
product.match_attributes[product_preferences[attribute.id]].push(attribute);
100+
101+
if (!(match_status_for_attribute in product.attributes_for_status)) {
102+
product.attributes_for_status[match_status_for_attribute] = [];
103+
}
104+
product.attributes_for_status[match_status_for_attribute].push(attribute);
105+
106+
product.match_attributes[attribute_preference].push(attribute);
75107
}
76108
});
77-
});
109+
});
110+
111+
// Normalize the score from 0 to 100
112+
if (sum_of_factors === 0) {
113+
score /= sum_of_factors;
114+
} else {
115+
score = 0;
116+
}
117+
118+
// If one of the attributes does not match, the product does not match
119+
if ("does_not_match" in product.attributes_for_status) {
120+
// Set score to 0 for products that do not match
121+
score = "0";
122+
product.match_status = "does_not_match";
123+
}
124+
else if ("may_not_match" in product.attributes_for_status) {
125+
product.match_status = "may_not_match";
126+
}
127+
// If too many attributes are unknown, set an unknown match
128+
else if (sum_of_factors_for_unknown_attributes >= sum_of_factors / 2) {
129+
product.match_status = "unknown_match";
130+
}
131+
// If the product matches, check how well it matches user preferences
132+
else if (score >= 75) {
133+
product.match_status = "very_good_match";
134+
}
135+
else if (score >= 50) {
136+
product.match_status = "good_match";
137+
}
138+
else {
139+
product.match_status = "poor_match";
140+
}
78141
}
79142
else {
80-
// the product does not have the attribute_group field
81-
status = "unknown";
143+
// the product does not have the attribute_groups field
144+
product.match_status = "unknown_match";
145+
debug = "no attribute_groups";
82146
}
83147

84-
product.match_status = status;
85-
product.match_score = score;
148+
product.match_score = score;
86149
product.match_debug = debug;
87150
}
88151

152+
89153
// rank_products (products, product_preferences)
90154

91155
// keep the initial order of each result
92156
var initial_order = 0;
93157

158+
// option to enable tabs in results to filter on product match status
159+
var show_tabs_to_filter_by_match_status = 0;
160+
94161
function rank_products(products, product_preferences, use_user_product_preferences_for_ranking) {
95162

96163
// Score all products
@@ -109,10 +176,12 @@ function rank_products(products, product_preferences, use_user_product_preferenc
109176

110177
if (use_user_product_preferences_for_ranking) {
111178

112-
// Rank all products, and return them in 3 arrays: "yes", "no", "unknown"
179+
// Rank all products
113180

114181
products.sort(function(a, b) {
115-
return (b.match_score - a.match_score) || (a.initial_order - b.initial_order);
182+
return (b.match_score - a.match_score) // Highest score first
183+
|| ((b.match_status === "does_not_match" ? 0 : 1) - (a.match_status === "does_not_match" ? 0 : 1)) // Matching products second
184+
|| (a.initial_order - b.initial_order); // Initial order third
116185
});
117186
}
118187
else {
@@ -123,14 +192,14 @@ function rank_products(products, product_preferences, use_user_product_preferenc
123192

124193
var product_groups = {
125194
"all" : [],
126-
"yes" : [],
127-
"unknown" : [],
128-
"no" : [],
129195
};
130196

131197
$.each( products, function(key, product) {
132198

133-
if (use_user_product_preferences_for_ranking) {
199+
if (show_tabs_to_filter_by_match_status && use_user_product_preferences_for_ranking) {
200+
if (! (product.match_status in product_groups)) {
201+
product_groups[product.match_status] = [];
202+
}
134203
product_groups[product.match_status].push(product);
135204
}
136205
product_groups.all.push(product);
@@ -161,14 +230,17 @@ function display_products(target, product_groups, user_prefs ) {
161230

162231
$.each( product_group, function(key, product) {
163232

164-
var product_html = "";
233+
var product_html = `<li><a href="${product.url}">`;
165234

166-
// Show the green / grey / colors for matching products only if we are using the user preferences
167-
let css_classes = 'list_product_a';
168235
if (user_prefs.use_ranking) {
169-
css_classes += ' list_product_a_match_' + product.match_status;
236+
product_html += `<div class="list_product_banner list_product_banner_${product.match_status}">`
237+
+ lang()["products_match_" + product.match_status] + ' ' + Math.round(product.match_score) + '%</div>'
238+
+ '<div class="list_product_content">';
170239
}
171-
product_html += `<li><a href="${product.url}" class="${css_classes}">`;
240+
else {
241+
product_html += '<div class="list_product_unranked">';
242+
}
243+
172244
product_html += '<div class="list_product_img_div">';
173245

174246
const img_src =
@@ -206,7 +278,6 @@ function display_products(target, product_groups, user_prefs ) {
206278
if (user_prefs.display.display_barcode) {
207279
product_html += `<span class="list_display_barcode">${product.code}</span>`;
208280
}
209-
product_html += "</a>";
210281
if (user_prefs.display.edit_link) {
211282
const edit_url = product_edit_url(product);
212283
const edit_title = lang().edit_product_page;
@@ -219,34 +290,41 @@ function display_products(target, product_groups, user_prefs ) {
219290
</a>
220291
`;
221292
}
222-
product_html += "</li>";
293+
product_html += "</div></a></li>";
223294

224295
products_html.push(product_html);
225296
});
297+
226298

299+
227300
var active = "";
228301
var text_or_icon = "";
229-
if (product_group_id == "all") {
302+
if (product_group_id === "all") {
230303
active = " active";
231-
if (product_group.length == 1) {
232-
text_or_icon = lang()["1_product"];
304+
}
305+
306+
if (show_tabs_to_filter_by_match_status) {
307+
if (product_group_id === "all") {
308+
if (product_group.length === 1) {
309+
text_or_icon = lang()["1_product"];
310+
}
311+
else {
312+
text_or_icon = product_group.length + ' ' + lang().products;
313+
}
233314
}
234315
else {
235-
text_or_icon = product_group.length + ' ' + lang().products;
316+
text_or_icon = '<img src="/images/attributes/match-' + product_group_id + '.svg" class="icon">'
317+
+ ' <span style="color:grey">' + product_group.length + "</span>";
318+
}
319+
320+
if (user_prefs.use_ranking) {
321+
$("#products_tabs_titles").append(
322+
'<li class="tabs tab-title tab_products-title' + active + '">'
323+
+ '<a id="tab_products_' + product_group_id + '" href="#products_' + product_group_id + '" title="' + lang()["products_match_" + product_group_id] + '">'
324+
+ text_or_icon
325+
+ "</a></li>"
326+
);
236327
}
237-
}
238-
else {
239-
text_or_icon = '<img src="/images/attributes/match-' + product_group_id + '.svg" class="icon">'
240-
+ ' <span style="color:grey">' + product_group.length + "</span>";
241-
}
242-
243-
if (user_prefs.use_ranking) {
244-
$("#products_tabs_titles").append(
245-
'<li class="tabs tab-title tab_products-title' + active + '">'
246-
+ '<a id="tab_products_' + product_group_id + '" href="#products_' + product_group_id + '" title="' + lang()["products_match_" + product_group_id] + '">'
247-
+ text_or_icon
248-
+ "</a></li>"
249-
);
250328
}
251329

252330
$("#products_tabs_content").append(
@@ -282,7 +360,7 @@ function display_product_summary(target, product) {
282360
// vary the color from green to red
283361
var grade ="unknown";
284362

285-
if (attribute.status == "known") {
363+
if (attribute.status === "known") {
286364
grade = attribute.grade;
287365
}
288366

lib/ProductOpener/Attributes.pm

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,7 @@ The return value is a reference to the resulting attribute data structure.
781781
782782
- NOVA 1: 100%
783783
- NOVA 2: 100%
784-
- NOVA 3: 50%
784+
- NOVA 3: 75%
785785
- NOVA 4: 0%
786786
787787
=cut
@@ -807,16 +807,14 @@ sub compute_attribute_nova($$) {
807807

808808
# Compute match based on NOVA group
809809

810-
my $match = 0;
811-
812-
if (($nova_group == 1) or ($nova_group == 2)) {
813-
$match = 100;
814-
}
815-
elsif ($nova_group == 3) {
816-
$match = 50;
817-
}
810+
my %nova_groups_scores = (
811+
1 => 100,
812+
2 => 100,
813+
3 => 75,
814+
4 => 0,
815+
);
818816

819-
$attribute_ref->{match} = $match;
817+
$attribute_ref->{match} = $nova_groups_scores{$nova_group + 0}; # Make sure the key is a number
820818

821819
if ($target_lc ne "data") {
822820
$attribute_ref->{title} = sprintf(lang_in_other_lc($target_lc, "attribute_nova_group_title"), $nova_group);
@@ -1182,7 +1180,7 @@ The return value is a reference to the resulting attribute data structure.
11821180
=head4 % Match
11831181
11841182
100: no indication of the allergen or trace of the allergen
1185-
20: may contain the allergen
1183+
20: may contain the allergen as a trace
11861184
0: contains allergen
11871185
11881186
=cut

0 commit comments

Comments
 (0)