diff --git a/AUTHORS.md b/AUTHORS.md
index b121c61..163641c 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -24,3 +24,4 @@
* Matthew Bentley [matthewbentley](https://github.com/matthewbentley)
* Kevin Hock [KevinHock](https://github.com/KevinHock)
* Duncan Cook [theletterd](https://github.com/theletterd)
+* Billy Montgomery [billyxs](https://github.com/billyxs)
\ No newline at end of file
diff --git a/README.md b/README.md
index fb4d9a3..353fe33 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ to set up billing and give Cloud Build permission to deploy your app.
### Prepare for deployment
-Copy the [example config](config-example.py) file to config.py and change the
+Copy the [example config](loveapp/config-example.py) file to config.py and change the
settings. Don't forget to specify your own SECRET_KEY.
### Initial deployment
@@ -85,7 +85,7 @@ using the [Secret](models/secret.py) model. Locally, you can temporarily add an
def create_secrets():
from loveapp.models import Secret
Secret(id='AWS_ACCESS_KEY_ID', value='change-me').put()
- Secret(id='AWS_SECRET_ACCESS_KEY', value='change-me').put()
+ Secret(id='AWS_SECRET_ACCESS_KEY', value='change-me').put()
return "please delete me now"
```
@@ -110,11 +110,67 @@ your favorite packet manager.
### Running the application locally
-* Check out the application code: git clone git@github.com:Yelp/love.git
-* Follow the [Prepare for deployment](#prepare-for-deployment) section
-* Run the app: make run-dev will start both [Yelp Love](http://localhost:8080) as well as the [Admin server](http://localhost:8000)
-* Follow the [CSV import](#csv) section to locally import user data
-* Make your changes
+Check out the application code and cd to the directory
+```
+$ git clone git@github.com:Yelp/love.git
+$ cd love
+```
+
+Create the virtual environment and activate it
+```
+$ python3 -m venv env
+$ source env/bin/activate
+```
+
+Install the requisite libraries
+```
+$ pip install -r requirements-dev.txt
+```
+
+Make a copy of loveapp/config.py
+```
+$ cp loveapp/config-example.py loveapp/config.py
+```
+
+Make a copy of employees.csv.example
+```
+cp import/employees.csv.example import/employees.csv
+```
+
+
+Edit employees.csv to add your own test data
+```
+username,first_name,last_name,department,office,photo_url
+michael,Michael,Scott,,,https://placehold.co/100x100
+jim,Jim,Halpert,,,https://placehold.co/100x100
+pam,Pamela,Beesly,,,https://placehold.co/100x100
+dwight,Dwight,Schrute,,,https://placehold.co/100x100
+angela,Angela,Martin,,,https://placehold.co/100x100
+ryan,Ryan,Howard,,,https://placehold.co/100x100
+stanley,Stanley,Hudson,,,https://placehold.co/100x100
+kelly,Kelly,Kapoor,,,https://placehold.co/100x100
+oscar,Oscar,Martinez,,,https://placehold.co/100x100
+creed,Creed,Bratton,,,https://placehold.co/100x100
+kevin,Kevin,Malone,,,https://placehold.co/100x100
+toby,Toby,Flenderson,,,https://placehold.co/100x100
+phyllis,Phyllis,Lapin,,,https://placehold.co/100x100
+meredith,Meredith,Palmer,,,https://placehold.co/100x100
+andy,Andy,Bernard,,,https://placehold.co/100x100
+bobvance,Vance,Refrigeration,,,https://placehold.co/100x100
+```
+
+Run the application
+```
+$ make run-dev
+```
+
+Go to http://localhost:8080/ and login as michael@example.com (or something matching a username in employees.csv), check the "Sign in as administrator" box.
+You'll probably get an error, but that's ok.
+
+Go to http://localhost:8080/employees/import
+click "Import"
+
+You're done!
## Deployment
@@ -218,14 +274,25 @@ curl "https://project_id.appspot.com/api/autocomplete?term=ha&api_key=secret"
## Original Authors and Contributors
-* [adamrothman](https://github.com/adamrothman)
-* [amartinezfonts](https://github.com/amartinezfonts)
-* [benasher44](https://github.com/benasher44)
-* [jetze](https://github.com/jetze)
-* [KurtisFreedland](https://github.com/KurtisFreedland)
-* [mesozoic](https://github.com/mesozoic)
-* [michalczapko](https://github.com/michalczapko)
-* [wuhuwei](https://github.com/wuhuwei)
+* Adam Rothman [adamrothman](https://github.com/adamrothman)
+* Ben Asher [benasher44](https://github.com/benasher44)
+* Andrew Martinez-Fonts [amartinezfonts](https://github.com/amartinezfonts)
+* Wei Wu [wuhuwei](https://github.com/wuhuwei)
+* Alex Levy [mesozoic](https://github.com/mesozoic)
+* Anthony Sottile [asottile](https://github.com/asottile)
+* Jenny Lemmnitz [jetze](https://github.com/jetze)
+* Kurtis Freedland [KurtisFreedland](https://github.com/KurtisFreedland)
+* MichaΕ Czapko [michalczapko](https://github.com/michalczapko)
+* Prayag Verma [pra85](https://github.com/pra85)
+* Stephen Brennan [brenns10](https://github.com/brenns10)
+* Wayne Crasta [waynecrasta](https://github.com/waynecrasta)
+* Dennis Coldwell [dencold](https://github.com/dencold)
+* Andrew Lau [ajlau](https://github.com/ajlau)
+* Alina Rada [transcedentalia](https://github.com/transcedentalia)
+* Matthew Bentley [matthewbentley](https://github.com/matthewbentley)
+* Kevin Hock [KevinHock](https://github.com/KevinHock)
+* Duncan Cook [theletterd](https://github.com/theletterd)
+* Billy Montgomery [billyxs](https://github.com/billyxs)
For more info check out the [Authors](AUTHORS.md) file.
diff --git a/loveapp/config-example.py b/loveapp/config-example.py
index 36fd889..21d0bca 100644
--- a/loveapp/config-example.py
+++ b/loveapp/config-example.py
@@ -53,3 +53,15 @@
CompanyValue('BE_EXCELLENT', 'Be excellent to each other', ('excellent', 'BeExcellent', 'WyldStallyns')),
CompanyValue('DUST_IN_THE_WIND', 'All we are is dust in the wind, dude.', ('woah', 'whoa', 'DustInTheWind'))
]
+
+# Highlight a user's avatar when their message contains a word or phrase that matches one of the options.
+# If a match is found, the emoji you provide will be added on the user's avatar.
+MESSAGE_EMOTES = {
+ "happy workiversary": "π₯³",
+ "happy birthday": "π",
+}
+
+# Temporaral separation of clustered loves.
+# i.e., if two loves have the same content but are sent more than this time apart,
+# they will be considered separate clusters.
+LOVE_CLUSTERING_TIME_WINDOW_DAYS = 7
diff --git a/loveapp/logic/love.py b/loveapp/logic/love.py
index b5db5a2..ad7f907 100644
--- a/loveapp/logic/love.py
+++ b/loveapp/logic/love.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
-from datetime import datetime
+from collections import defaultdict
+from datetime import datetime, timedelta
from google.appengine.api import taskqueue
@@ -27,8 +28,82 @@ def _love_query(start_dt, end_dt, include_secret):
query = query.filter(Love.secret == False) # noqa
return query
+def cluster_loves_by_time(loves, time_window_days=None):
+ time_window_days = time_window_days or config.LOVE_CLUSTERING_TIME_WINDOW_DAYS
+ # Phase 1: group by content and secrecy
+ groups = defaultdict(list)
+ for love in loves:
+ # key is a combo of message and secret status, since secret loves
+ # shouldn't be grouped with non-secret ones
+ key = love.message.strip().lower() + f"_secret={love.secret}"
+ groups[key].append(love)
+
+ # Phase 2: within each group, do temporal clustering
+ clustered_groups = []
+ threshold = timedelta(days=time_window_days)
+
+ for loves in groups.values():
+ # Sort by timestamp
+ loves.sort(key=lambda l: l.timestamp)
+ current_cluster = []
+ last_time = None
+ for love in loves:
+ if not current_cluster:
+ current_cluster.append(love)
+ last_time = love.timestamp
+ else:
+ if love.timestamp - last_time > threshold:
+ # Finish old cluster, start new one
+ clustered_groups.append(current_cluster)
+ current_cluster = [love]
+ else:
+ current_cluster.append(love)
+ last_time = love.timestamp
+ if current_cluster:
+ clustered_groups.append(current_cluster)
+
+ # Annotate each cluster with its representative timestamp and collect unique senders and recipients
+ results = []
+ for cluster in clustered_groups:
+ newest_time = max(love.seconds_since_epoch for love in cluster)
+
+ # Use ordered data structures to preserve the order of appearance
+ unique_sender_keys = []
+ unique_recipient_keys = []
+
+ # First pass - collect unique keys while preserving order
+ for love in cluster:
+ # Add sender key if not already in the list
+ if love.sender_key not in unique_sender_keys and love.sender_key:
+ unique_sender_keys.insert(0, love.sender_key)
+
+ # Add recipient key if not already in the list
+ if love.recipient_key not in unique_recipient_keys and love.recipient_key:
+ unique_recipient_keys.insert(0, love.recipient_key)
+
+ # Second pass - get the actual objects
+ senders = [key.get() for key in unique_sender_keys]
+ recipients = [key.get() for key in unique_recipient_keys]
+
+ results.append({
+ 'content': cluster[0].message,
+ 'emote': cluster[0].emote,
+ 'is_secret': cluster[0].secret, # Add is_secret flag to result
+ 'senders': senders,
+ 'recipients': recipients,
+ 'sender_count': len(senders),
+ 'recipient_count': len(recipients),
+ 'most_recent_love_timestamp': newest_time,
+ })
+
+ # Sort by most recent timestamp
+ results.sort(key=lambda x: x['most_recent_love_timestamp'], reverse=True)
+ # Return the sorted list of clusters
+ return results
+
def _sent_love_query(employee_key, start_dt, end_dt, include_secret):
+
return _love_query(start_dt, end_dt, include_secret).filter(Love.sender_key == employee_key)
diff --git a/loveapp/models/love.py b/loveapp/models/love.py
index eafa84a..a860723 100644
--- a/loveapp/models/love.py
+++ b/loveapp/models/love.py
@@ -5,6 +5,8 @@
from loveapp.models import Employee
+import loveapp.config as config
+
class Love(ndb.Model):
"""Models an instance of sent love."""
@@ -18,3 +20,12 @@ class Love(ndb.Model):
@property
def seconds_since_epoch(self):
return int(mktime(self.timestamp.timetuple()))
+
+ @property
+ def emote(self) -> str | None:
+ message = self.message
+ for message_substring, emoji in config.MESSAGE_EMOTES.items():
+ if message and message_substring.lower() in message.lower():
+ return emoji
+
+ return None
\ No newline at end of file
diff --git a/loveapp/themes/default/static/css/style.css b/loveapp/themes/default/static/css/style.css
index a46e0cc..a1e2752 100644
--- a/loveapp/themes/default/static/css/style.css
+++ b/loveapp/themes/default/static/css/style.css
@@ -47,10 +47,18 @@ h2 {
.love-message-container {
margin-bottom: 20px;
clear: left;
+ position: relative;
}
.love-message {
margin-left: 60px;
+ background-color: #f8f8f8;
+ padding: 10px 20px;
+ border-radius: 5px;
+}
+
+.user-list {
+ margin-top: 10px;
}
.love-byline {
@@ -352,3 +360,248 @@ h4 {
-2px 0px 0px #000,
-2px -1px 0px #000;
}
+
+.badge {
+ vertical-align: text-bottom;
+ border-radius: 5px;;
+}
+
+.loves-columns {
+ column-count: 2;
+ column-gap: 2em;
+}
+
+.love-message-container {
+ break-inside: avoid;
+ margin-bottom: 1.5em;
+ display: block;
+}
+
+.loves-columns .love-message-container {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.love-message-container .emote {
+ position: absolute;
+ top: -10px; left: 25px;
+ }
+
+a.love-plus-one,
+a.love-plus-one:active {
+ outline: none;
+ background: none;
+ border: none;
+ padding: 0;
+ margin-left: 8px;
+ cursor: pointer;
+ font-weight: bold;
+}
+
+.love-plus-one.disabled {
+ cursor: not-allowed;
+}
+
+.love-plus-one .circle {
+ display: inline-block;
+ width: 20px;
+ height: 18px;
+ position: relative;
+ transform: rotate(-45deg);
+ margin: 2px 4px;
+ background: #ea050b;
+ color: white;
+ text-align: center;
+ font-size: 12px;
+ transition: transform 0.2s ease-in-out, background 0.2s ease-in-out;
+ transform: scale(1);
+}
+.love-plus-one .circle:before,
+.love-plus-one .circle:after {
+ content: '';
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: inherit;
+ position: absolute;
+ z-index: -1;
+}
+.love-plus-one .circle:before {
+ top: -10px;
+ left: 0;
+}
+.love-plus-one .circle:after {
+ top: 0;
+ right: -10px;
+}
+
+/* Disabled state */
+.love-plus-one.disabled .circle {
+ background: #aaa;
+ cursor: default;
+ pointer-events: none;
+}
+
+.love-plus-one:not(.disabled):hover .circle {
+ background: #c41200;
+ transition: transform 0.3s ease-in-out, background 0.3s ease-in-out;
+ transform: scale(1.1);
+}
+
+.pagination {
+ display: flex;
+ justify-content: center;
+ gap: 2px;
+ align-items: center;
+ }
+ .pagination a, .pagination span {
+ padding: 6px 12px;
+ margin: 0 2px;
+ border-radius: 3px;
+ text-decoration: none;
+ color: #555;
+ background: #fff;
+ font-size: 1em;
+ }
+ .pagination a:hover {
+ background: #f0f0f0;
+ }
+ .pagination span.active {
+ background: #e00707;
+ color: #fff;
+ border-color: #e00707;
+ font-weight: bold;
+ pointer-events: none;
+ }
+ .pagination span.disabled {
+ color: #ccc;
+ border-color: #eee;
+ background: #f9f9f9;
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+
+ .user-list {
+ position: relative;
+ overflow: hidden;
+ max-height: 45px;
+ transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1);
+ margin-top: 7px;
+ padding-bottom: 2px;
+ padding-right: 36px;
+ background: #f8f8f8;
+ }
+
+ .user-list.user-list-expanded {
+ max-height: 500px;
+ }
+
+ .user-list-gradient {
+ pointer-events: none;
+ position: absolute;
+ left: 0; right: 0; bottom: 0;
+ height: 30px;
+ background: linear-gradient(to bottom, rgba(250,250,251,0), #f8f8f8);
+ transition: opacity 0.2s;
+ display: none;
+ }
+ .user-list.is-collapsible .user-list-gradient { display: block; }
+ .user-list.user-list-expanded .user-list-gradient { opacity: 0; }
+
+ .user-list-expander {
+ position: absolute;
+ right: 0px; /* tweak as needed */
+ bottom: 8px;
+ z-index: 2;
+ background: rgba(255,255,255,0.98);
+ border: 1px solid #eee;
+ padding: 2px 8px;
+ border-radius: 12px;
+ cursor: pointer;
+ box-shadow: 0 0 4px rgba(0,0,0,0.03);
+ transition: background 0.2s, box-shadow 0.2s;
+ display: none; /* default; toggled by JS (see below) */
+ }
+
+ .user-list.is-collapsible .user-list-expander { display: inline-block; }
+
+ .user-list.user-list-expanded .user-list-expander .chevron {
+ transform: rotate(225deg);
+ }
+
+ .user-list-expander .chevron {
+ display: inline-block;
+ width: 0.4em;
+ height: 0.4em;
+ vertical-align: middle;
+ border-right: 2px solid #888;
+ border-bottom: 2px solid #888;
+ padding: 3px;
+ margin: 0;
+ /* default: down */
+ transform: rotate(45deg);
+ transition: transform 0.2s;
+ }
+
+ /* visually hidden text for screen readers */
+ .sr-only {
+ position: absolute;
+ width: 1px; height: 1px;
+ padding: 0; margin: -1px;
+ overflow: hidden;
+ clip: rect(0,0,0,0); border: 0;
+ }
+
+ .love-plus-one-container {
+ position: relative;
+ float: right;
+ margin: -15px -15px 0 0;
+ }
+
+
+ .love-plus-one .custom-tooltip {
+ visibility: hidden;
+ width: 140px;
+ background-color: #222;
+ color: #fff;
+ text-align: center;
+ border-radius: 0.25rem;
+ padding: 0.5em;
+ position: absolute;
+ z-index: 1000;
+ /* Position from the right side */
+ top: 50%; /* vertically center */
+ left: 100%; /* to the right of the element */
+ transform: translateY(-50%); /* perfect vertical centering */
+ margin-left: 2em; /* increased spacing to move tooltip further right */
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s;
+ font-size: 0.5em;
+ box-shadow: 0px 3px 12px rgba(0,0,0,0.15);
+ }
+
+ .love-plus-one .custom-tooltip::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ right: 100%; /* at the left edge of tooltip */
+ transform: translateY(-50%);
+ border-width: 6px;
+ border-style: solid;
+ border-color: transparent #222 transparent transparent; /* arrow pointing left */
+ }
+
+ .love-plus-one:hover .custom-tooltip,
+ .love-plus-one:focus .custom-tooltip {
+ visibility: visible;
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ /* Optionally: show "not-allowed" cursor for disabled state */
+ .love-plus-one.disabled {
+ cursor: not-allowed;
+ pointer-events: auto;
+ }
+
diff --git a/loveapp/themes/default/static/js/main.js b/loveapp/themes/default/static/js/main.js
index e0a332b..5e68fca 100644
--- a/loveapp/themes/default/static/js/main.js
+++ b/loveapp/themes/default/static/js/main.js
@@ -24,7 +24,218 @@ function setupLinkify() {
$('.love-message').linkify();
}
+/**
+ * Initialize autocomplete and other functionality for the love form
+ */
+function initLoveForm() {
+ if (!$('#send-love-form').length) {
+ return; // Exit if we're not on a page with the love form
+ }
+
+ $('#nav-send').addClass('active');
+ $('#secret-love-label').tooltip();
+ $('input[name="recipients"]').focus();
+ if ($('input[name="recipients"]').val() != '') {
+ var messageText = $('textarea[name="message"]').val();
+ $('textarea[name="message"]').focus().val('').val(messageText);
+ }
+ $('#love-error').hide();
+
+ // Hashtags autocomplete - code heavily sampled from http://jqueryui.com/autocomplete/#multiple-remote
+ $('textarea[name="message"]')
+ .bind('keydown', function(event) {
+ if (event.keyCode === $.ui.keyCode.TAB &&
+ $(this).data("ui-autocomplete").menu.active) {
+ event.preventDefault();
+ }
+ })
+ .autocomplete({
+ source: function(request, response) {
+ $.getJSON('/values/autocomplete', {
+ term: request.term.split(/\s/).pop()
+ }, response);
+ },
+ search: function() {
+ var term = this.value.split(/\s/).pop();
+ if (!term.startsWith("#")) {
+ return false;
+ }
+ return term;
+ },
+ focus: function() { return false; },
+ select: function(event, ui) {
+ var terms = this.value.split(/\s/);
+ terms.pop();
+ terms.push(ui.item.value + " ");
+ this.value = terms.join(" ");
+ return false;
+ },
+ minLength: 1,
+ delay: 5,
+ autoFocus: true
+ });
+
+ // Recipients autocomplete
+ $('input[name="recipients"]')
+ // don't navigate away from the field on tab when selecting an item
+ .bind('keydown', function(event) {
+ if (event.keyCode === $.ui.keyCode.TAB &&
+ $(this).autocomplete().menu.active) {
+ event.preventDefault();
+ }
+ })
+ .autocomplete({
+ source: function(request, response) {
+ $.getJSON('/user/autocomplete', {
+ term: extractLast(request.term)
+ }, response);
+ },
+ search: function() {
+ // set minLength before attempting autocomplete
+ var term = extractLast(this.value);
+ if (term.length < 2) {
+ return false;
+ }
+ },
+ focus: function() {
+ return false;
+ },
+ select: function(event, ui) {
+ var terms = split(this.value);
+ // remove the current input
+ terms.pop();
+ // add the selected item
+ terms.push(ui.item.value);
+ // add placeholder to get the comma-and-space at the end
+ terms.push('');
+ this.value = terms.join(', ');
+ return false;
+ },
+ }).data('ui-autocomplete')._renderItem = function(ul, item) {
+ return $("