Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4b48455
moved JS from the template into main.js
theletterd May 13, 2025
bce9e31
add message tags with yelpiversary highlight
billyxs May 13, 2025
0634687
move tag handling to love model
billyxs May 13, 2025
3934379
grouped love works!
theletterd May 13, 2025
bb81824
cleanup plus make tests pass
theletterd May 13, 2025
2fca154
change tags to emote
billyxs May 13, 2025
83ee0d2
fix MESSAGE_EMOTES comments
billyxs May 13, 2025
6450262
add test
billyxs May 13, 2025
200ac1a
tests for clustering
theletterd May 13, 2025
ac5be02
Merge remote-tracking branch 'origin/add_message_tags_with_yelpiversa…
theletterd May 14, 2025
44679ad
improve clustering performance and add a config for clustering threshold
theletterd May 14, 2025
27c5dd9
pagination
theletterd May 14, 2025
c8fc74f
add plus one action
billyxs May 14, 2025
6fa2e5a
pagination and tests
theletterd May 14, 2025
6e865d2
merge and fix conflicts
billyxs May 14, 2025
5369e21
Merge branch 'add_paging_and_stuff' into plus_one_message
theletterd May 14, 2025
e3dd2b9
Merge pull request #3 from theletterd/plus_one_message
theletterd May 14, 2025
d2734b0
+1 love to all recipients
theletterd May 15, 2025
b76f1be
nicer styling
theletterd May 15, 2025
90ee7eb
make +1 a redirect to the love form
billyxs May 15, 2025
cb53fc1
fixed animation mistiming
theletterd May 15, 2025
abe52a3
Merge remote-tracking branch 'origin/add_paging_and_stuff' into add_p…
theletterd May 15, 2025
93921aa
being a little nicer to grouped_love
theletterd May 15, 2025
8eea49f
hide +1 when current user is a sender
billyxs May 15, 2025
cc41c06
minor UI adjustment
theletterd May 15, 2025
11d4ef5
hide +1 when current user is the only recipient
billyxs May 15, 2025
d16ac69
forgot to switch recipients for senders in me.html
theletterd May 15, 2025
762a3d6
add +1 disabled state
billyxs May 15, 2025
f533ba9
tooltips
theletterd May 15, 2025
ccc149a
only populate the love-link if the +1 is not disabled
theletterd May 15, 2025
bb6b7de
update README.md
theletterd May 15, 2025
fd74eea
add billyxs to the AUTHORS.md file
theletterd May 15, 2025
6f3a3ea
syncing the authors in README.md with AUTHORS.md
theletterd May 15, 2025
ba19655
cleanup and fix a styling issue
theletterd May 24, 2025
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
1 change: 1 addition & 0 deletions AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
97 changes: 82 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
```

Expand All @@ -110,11 +110,67 @@ your favorite packet manager.

### Running the application locally

* Check out the application code: <code>git clone git@github.com:Yelp/love.git</code>
* Follow the [Prepare for deployment](#prepare-for-deployment) section
* Run the app: <code>make run-dev</code> 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

Expand Down Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions loveapp/config-example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
77 changes: 76 additions & 1 deletion loveapp/logic/love.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)


Expand Down
11 changes: 11 additions & 0 deletions loveapp/models/love.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from loveapp.models import Employee

import loveapp.config as config


class Love(ndb.Model):
"""Models an instance of sent love."""
Expand All @@ -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
Loading