How I saved $49/mo with 600 lines of code

Matt Basta
9 min readApr 20, 2017

In the last few months, I’ve spent a lot of time iterating on Pinecast, my podcast hosting service. Hosting podcasts is pretty straightforward: the core functionality is very cut-and-dry. Connect an S3 bucket to an RSS feed and you’re good to go. Some users need more features than just hosting, though: podcasts need websites, clear analytics are necessary to understand an audience, and quality-of-life improvements are often necessary to keep a customer from switching to a competitor.

I’ve run a support portal for a while, but I wanted to get more direct feedback from my users about what exactly they want. I can give them ideas of features that I want, but I could easily be missing something. I also wanted to gather priority: a feature that one user wants is less important than a feature that many users want. I needed a way for users to vote on the features they wanted most.

While reading through my inbox, I saw a post about the service Canny. Canny was exactly what I wanted: a clean, easy-to-integrate way to allow my users to post and vote for features. Looking at the plans, it seemed inexpensive enough, too. $2/mo for the Starter plan? Sure! I signed up and started the integration.

There is no free lunch

As with any new toy, the wow-factor quickly wore off. First, the SSO integration (the ability to have your users automatically have an account on Canny) didn’t have a Python code sample. “Simple enough,” I thought, until I realized that it involved crypto.

Unlike most other languages, there’s no easily-accessible crypto library for Python. Go search for “python crypto library”. You’ll notice the first result, pycrypto. You’d be misled by this search result, as pycrypto doesn’t support Python 3.4 and up (Pinecast runs on 3.5). I tried porting the other code samples to work with the cryptography library, which has much better support, but I ultimately failed. After a back-and-forth with an engineer at Canny, we got a version working. That said, I still don’t understand why or how it works.

After getting their widget embedded, I faced some more problems. For one, they require users’ names. I don’t know what kind of service asks for users’ real names when they sign up, but Pinecast is not among them. I ended up writing a silly hashing function to generate silly names from the users’ emails.

Another problem was customizability. Despite allowing you to change the labels of the form fields in the widget, the “Create a post” text couldn’t be changed to “Suggest a feature” or something similar. “Post” isn’t a very clear term in the context of Pinecast’s feedback page.

The widget was also very slow to load. After arriving on the page I’d created for it, the Canny widget took almost three seconds on my cable internet to appear. No loading indicator, no loading text, just a big empty box. An issue on their own feature requests board was opened for this in March.

Last, when the user’s email in the SSO token changes, it doesn’t update their email in Canny. That is, if a user changes their email in Pinecast, it doesn’t update their email in Canny. If a post they created is updated, they won’t receive an email notification (no fault of their own). I reported this to Canny, but they said this is a planned feature without much more explanation.

Canny’s UI, on the pinecast.canny.io subdomain

All that said, the widget did its job and I collected a decent number of suggestions.

When I learned to read

I should have read the fine print.

At the end of my one month free trial, I had a frustrating realization: the $2/month plan was not what I thought. In fact, it’s completely unusable for Pinecast.

  • It doesn’t support the widget; users must go to Canny’s website.
  • It doesn’t give me a subdomain on Canny, so it’s essentially unbranded.
  • There’s no “SSO” support, so my users would have to maintain a second, separate identity on Canny. It’s unclear whether their existing SSO identity would map up to the new one

The only viable plan is the $49/mo option (named “Small Team”). For that price, you get essentially what you got with the free trial. There’s a $19/mo option (cutely named “Side Project”), but that lacks SSO support. I’d consider it, but the Heroku bill for Pinecast isn’t even $19/mo and having one page that users can vote for features with really doesn’t justify the cost.

Sorry Canny, it wasn’t meant to be.

Plan B

The whole reason I started Pinecast over a year ago was because I was so salty about the poor choices available in podcast hosts, I’m an engineer, and engineers solve problems themselves. This would be no different.

First, I created models in Django for the new feature:

class UserSuggestion(models.Model):
created = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=512)
description = models.TextField(blank=True)
suggester = models.ForeignKey(User, related_name='user_suggestions')
STATUS_CHOICES = (
('open', ugettext_lazy('Open')),
('closed', ugettext_lazy('Closed')),
('in progress', ugettext_lazy('In Progress')),
('complete', ugettext_lazy('Complete')),
)
status = models.CharField(choices=STATUS_CHOICES, default='open', max_length=max(len(k) for k, _ in STATUS_CHOICES))
def __str__(self):
return self.title
class UserSuggestionVote(models.Model):
suggestion = models.ForeignKey(UserSuggestion, related_name='votes')
voter = models.ForeignKey(User)
class UserSuggestionComment(models.Model):
created = models.DateTimeField(auto_now_add=True)
suggestion = models.ForeignKey(UserSuggestion, related_name='comments')
commenter = models.ForeignKey(User)
body = models.TextField(blank=True)

This isn’t a trophy by any means, but it does the job just fine.

Next, I added a few routes:

urlpatterns = [
# ...
url(r'^userfeedback$', views_usersuggestions.feedback, name='usersuggestions'),
url(r'^userfeedback/new$', views_usersuggestions.feedback_new, name='usersuggestions_new'),
url(r'^userfeedback/toggle_vote$', views_usersuggestions.feedback_toggle, name='usersuggestions_toggle'),
url(r'^userfeedback/delete$', views_usersuggestions.feedback_delete, name='usersuggestions_delete'),
url(r'^userfeedback/comments/(?P<id>[\w-]+)$', views_usersuggestions.feedback_comments, name='usersuggestions_comments'),
url(r'^userfeedback/comments/(?P<id>[\w-]+)/new$', views_usersuggestions.feedback_comment, name='usersuggestions_comment'),
]

I won’t cover all the views, but you can see them for yourself on Github.

First, I needed to render a page with the suggestions. Easy!

@login_required
@require_GET
def feedback(req):
suggestions = (
UserSuggestion.objects
.exclude(status='complete')
.annotate(
Count('votes', distinct=True),
Count('comments', distinct=True))
.order_by('-votes__count', '-created')
)
user_votes = UserSuggestionVote.objects.filter(
voter=req.user,
suggestion__in=suggestions,
).values('suggestion_id')
ctx = {
'was_added': 'added' in req.GET,
'did_vote': 'voted' in req.GET,
'did_delete': 'deleted' in req.GET,
'suggestions': suggestions,
'user_votes': {v['suggestion_id'] for v in user_votes},
}
return render(req, 'user_suggestions.html', ctx)

First, we query the UserSuggestion objects. The annotate() method on Django querysets allows you to tack on bonus values that come from functions like COUNT and SUM. The output of this query didn’t work well at all. In short, the vote and comment counts were not even close, counting up by two, and displaying general insanity. Upon peeking at the Django docs, I discovered this gem:

Combining multiple aggregations with annotate() will yield the wrong results because joins are used instead of subqueries

Why they even have this feature if it’s just broken as heck is completely beyond me. Adding distinct=True to the Count objects fixes the issue — but only for Count. If you’re trying to make it work with another aggregate function, godspeed in your search.

The next thing we do is query the things the user has voted for. We can simply query by the user and the list of suggestions we just fetched. I’m using the values() function because I don’t want to accidentally lazily query the suggestion or voter fields again.

Last, I throw all that into a context object and feed that to my render method.

I considered building the UI with React, but that’s honestly a lot of work, and would be substantially less usable in the end. I opted for a plain-old Jinja template with a sprinkling of POJO. Here’s what replaced the embed widget:

<form class="sidebar" action="{{ url('usersuggestions_new') }}" method="post">
<h2>{{ _('Suggest a feature') }}</h2>
<label>
<span>{{ _('Title') }}</span>
<input name="title" placeholder="{{ _('Short, descriptive title') }}">
</label>
<label>
<span>{{ _('Details') }}</span>
<textarea name="description" placeholder="{{ _('Any additional details') }}"></textarea>
</label>
<div style="display: flex; max-width: 300px; justify-content: flex-end;">
<button class="btn-accent">{{ _('Submit') }}</button>
</div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
<div>
{% if was_added %}
<div class="success">{{ _('Your suggestion was added!') }}</div>
{% endif %}
{% if did_vote %}
<div class="success">{{ _('Your vote was updated!') }}</div>
{% endif %}
{% for suggestion in suggestions %}
<div class="suggestion">
{% if suggestion.suggester_id != user_id %}
<form action="{{ url('usersuggestions_toggle') }}?id={{ suggestion.id }}" method="post">
{% if suggestion.id not in user_votes %}
<button aria-label="{{ _('Vote') }}" class="vote-btn btn-plain" title="{{ _('Vote') }}">
<i class="icon icon-angle-up"></i>
</button>
{% else %}
<button aria-label="{{ _('Unvote') }}" class="unvote-btn btn-plain" title="{{ _('Unvote') }}">
<i class="icon icon-angle-down"></i>
</button>
{% endif %}
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
{% else %}
<form action="{{ url('usersuggestions_delete') }}?id={{ suggestion.id }}" method="post">
<button aria-label="{{ _('Delete') }}" class="unvote-btn btn-plain" title="{{ _('Delete') }}">
<i class="icon icon-trash-empty"></i>
</button>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
{% endif %}
<span class="vote-count">{{ suggestion.votes__count }}</span>
<div class="title-box">
<a href="" class="show-comments">
{{ suggestion.title }}
{% if suggestion.status != 'open' %}
<span class="status-tag">{{ suggestion.status }}</span>
{% endif %}
</a>
</div>
<a class="show-comments comment-count" href="">
{{ suggestion.comments__count }}
<i class="icon icon-megaphone"></i>
</a>
<div class="comment-container" data-url="{{ url('usersuggestions_comments', id=suggestion.id) }}"></div>
</div>
{% endfor %}
</div>

Some notes:

  • Yes, those are inline styles. I don’t put tiny, one-off styles in the stylesheet. That’s how you make a mess.
  • Yes, I could make a macro for the CSRF input. I probably should. But it’s not such a problem. Pull requests welcome.
  • The _() function is an alias of ugettext(). The url() function is a wrapper around Django’s built-in reverse() function, but with support for **kwargs because I’m not a barbarian.

Last step was making the whole thing interactive. I dumped this into a script tag at the bottom of the page:

// Bind the links to open the comments modal
let openComments;
Array.from(document.querySelectorAll('.show-comments')).forEach(cc => {
cc.addEventListener('click', e => {
e.preventDefault();
if (openComments) {
closeComment();
}
openComment(
cc.parentNode.querySelector('.comment-container') ||
cc.parentNode.parentNode.querySelector('.comment-container')
);
});
});
function openComment(node) {
openComments = node;
openComments.classList.add('is-open');
if (node.hasAttribute('data-is-loaded')) {
return;
}
const xhr = new XMLHttpRequest();
xhr.open('get', node.getAttribute('data-url'), true);
xhr.send();
xhr.onload = () => {
node.innerHTML = xhr.responseText;
node.setAttribute('data-is-loaded', 'true');
};
xhr.onerror = () => {
node.innerHTML = '{{ _('Sorry, there was a problem loading the comments.') }}';
};
}
function closeComment() {
if (!openComments) {
return;
}
openComments.classList.remove('is-open');
openComments = null;
}
// Handle the close button on the comments modal
document.body.addEventListener('click', e => {
if (!e.target.classList.contains('comment-close-btn')) {
return;
}
e.preventDefault();
closeComment();
});
// Handle comment submission
document.body.addEventListener('submit', e => {
if (!e.target.classList.contains('comment-submit')) {
return;
}
e.preventDefault();
const xhr = new XMLHttpRequest();
xhr.open('post', e.target.action, true);
xhr.send(new FormData(e.target));
const parent = e.target.parentNode;
xhr.onload = () => openComment(parent);
parent.innerHTML = '';
parent.removeAttribute('data-is-loaded');
openComment(parent);
});

Yes, I used an inline script. It’s like 70 lines of completely encapsulated JavaScript code. It takes more boilerplate to get a React component on the page than it did for me to build my full UI. Fight me.

Areas for improvement

  • I use a query string parameter for the Id of certain endpoints, and a path segment for others. I’d convert them all to path segments.
  • The modal needs a loading indicator. I could do it entirely with CSS animations and pseudo-elements, but I’m lazy.
  • There’s no pagination on the suggestions list. Django makes it easy to paginate, but I’m lazy, and hopefully I never have so many suggestions that pagination is necessary.
  • It’s not easy to see whether you’re submitting a duplicate, but that’s more on me than the user. I’d rather the user submit duplicates that I then merge.
  • There’s no easy way to merge suggestions. I’d add a nullable field to link one suggestion to another as “merged,” and add a status for it.
  • I’d spend another couple hours polishing the UI. I recognize that I’m not great at visual design, and what I built is aesthetically suboptimal.

That’s it.

That is honestly all it took. Pushing it to production took about a half hour so I could walk through the dozen or so suggestions, votes, and comments and add them to the database with the right user references.

When all was said and done, the whole thing clocked in at around 550 lines of code. Not bad for saving $50/month.

Some things that I didn’t bring over from Canny:

  • The ability to upload images for posts and comments.
  • The ability for users to edit posts.
  • The ability to delete/edit comments.
  • Searching and reordering posts.
  • Ability to see who else voted for an idea. Granted, this was kind of useless because all the names are silly fake names.
  • Some other minor user-facing features that aren’t worth noting.

I’m not losing sleep over any of those things.

If you enjoyed this post and want to check out Pinecast, you can sign up with no credit card required to try it out for as long as you like. If you decide that a paid plan is right for you, you can use the code uncanny for 50% off your first two months of service on any plan through the end of May.

--

--

Matt Basta

A salty software guy. Stripe by day, Pinecast by night.