Steve's Real Blog

irskep, steveasleep.com, slam jamsen, diordna, etc

MDN is great for a reference, but I haven't found a source of truth for modern CSS best practices. Once in a while I run across an article that captures a small piece of it. Here's a list of those articles.

Hit me up on Mastodon if I should add anything to the list.

There are three patterns I use in most of my UIKit projects that I've never seen anyone else talk about. I think they help readability a lot, so I'm sharing them here:

  1. An addSubviews method to define your view hierarchy all at once
  2. An @AssignedOnce property wrapper
  3. A pattern for keeping view creation at the bottom of a file to keep the top clean

addSubviews

I've seen a lot of view and view controller code that looks like this:

override func loadView() {
  view = UIView()
  let scrollView = UIScrollView()
  view.addSubview(scrollView)
  let contentView = MyContentView()
  scrollView.addSubview(contentView)
  let topLabel = UILabel()
  let button = UIButton()
  contentView.addSubview(topLabel)
  contentView.addSubview(button)
}

This style of setup is straightforward, but I usually have a difficult time understanding the view hierarchy without spending more time than I'd like.

In most of my projects, I use this simple extension to help with this problem:

extension UIView {
  func addSubviews(_ subviews: UIView...) -> UIView {
    subviews.forEach { self.addSubview($0) }
    return self
  }
}

Now, it's possible for the calls to addSubviews() to visually resemble the view hierarchy!

override func loadView() {
  view = UIView()
  let scrollView = UIScrollView()
  let contentView = MyContentView()
  let topLabel = UILabel()
  let button = UIButton()
  
  view.addSubviews(
    scrollView.addSubviews(
      contentView.addSubviews(
        topLabel,
        bottomLabel)))
}

You can also use this pattern in UIView initializers.

@AssignedOnce

When using storyboards, you commonly need to use force-unwrapped optional vars to keep references to views and other things. fine, but there is no compile-time guarantee that the property can't be overwritten. Kotlin solves this problem with the lateinit keyword, but Swift has no equivalent.

You can at least prevent multiple writes to vars at runtime by using this simple property wrapper, which throws an assertion failure in debug builds if you write to the property more than once. It's not as good as a compile-time guarantee, but it does double as inline documentation.

@propertyWrapper
public struct AssignedOnce<T> {
  #if DEBUG
    private var hasBeenAssignedNotNil = false
  #endif

  public private(set) var value: T!

  public var wrappedValue: T! {
    get { value }
    
    // Normally you don't want to be running a bunch of extra code when storing values, but
    // since you should only be doing it one time, it's not so bad.
    set {
      #if DEBUG
        assert(!hasBeenAssignedNotNil)
        if newValue != nil {
          hasBeenAssignedNotNil = true
        }
      #endif

      value = newValue
    }
  }

  public init(wrappedValue initialValue: T?) {
    wrappedValue = initialValue
  }
}

In practice, you can just add @AssignedOnce in front of any properties you want to prevent multiple assignment to:

class MyViewController: UIViewController {
  @AssignedOnce var button: UIButton! // assigned by storyboard
  @AssignedOnce var label: UILabel! // assigned by storyboard
}

Looks pretty nice, right?

View Factories

The most critical part of any source code file is the first hundred lines. If you're browsing through code, it really helps to not have to scroll very much to see what's going on.

Unfortunately, it's very easy to gum up the top of a view view controller file by creating subviews over multiple lines, especially if (like me) you don't use storyboards at all. Here's what I mean:

class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
  // I'm declaring all these as FUO `var`s instead of `let`s
  // so I can instantiate them in loadView().
  @AssignedOnce private var headerLabel: UILabel!
  @AssignedOnce private var tableView: UITableView!
  @AssignedOnce private var continueButton: UIButton!
  
  override func loadView() {
    view = UIView()
    
    headerLabel = UILabel()
    headerLabel.text = NSLocalizedString("List of things:", comment: "")
    headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
    headerLabel.textAlignment = .center
    headerLabel.textColor = UIColor.systemBlue
    
    continueButton = UIButton()
    continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
    continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
    
    tableView = UITableView()
    tableView.dataSource = self
    tableView.delegate = self
    // more semi-arbitrary table view configuration
    tableView.separatorStyle = .none
    tableView.showsVerticalScrollIndicator = false
    
    view.am_addSubviews(
      headerLabel,
      tableView,
      continueButton)
      
    // ... add constraints ...
  }
  
  // MARK: Actions
  
  @objc private func continueAction() {
    dismiss(animated: true, completion: nil)
  }
  
  // MARK: UITableViewDataSource
  
  /* ... */
  
  // MARK: UITableViewDelegate
  
  /* ... */
}

This is OK, but do you really need to know the implementation details of all the views so near the top of the file? In my experience, those parts of the code are written once and then never touched again.

Additionally, it's not great to use force-unwrapped optionals to store anything. But if we use let instead, then all views will be created at init time instead of in loadView().

View factories

We can solve a lot of problems by moving all view creation to the bottom of the file and using lazy var.

class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
  private lazy var headerLabel = makeHeaderLabel()
  private lazy var tableView = makeTableView()
  private lazy var continueButton = makeContinueButton()
  
  override func loadView() {
    view = UIView()
    
    view.am_addSubviews(
      headerLabel,
      tableView,
      continueButton)
      
    // ... add constraints ...
  }
  
  // MARK: Actions
  
  @objc private func continueAction() {
    dismiss(animated: true, completion: nil)
  }
  
  // MARK: UITableViewDataSource
  
  /* ... */
  
  // MARK: UITableViewDelegate
  
  /* ... */
  
  // MARK: View factories
  
  private func makeHeaderLabel() -> UILabel {
    let headerLabel = UILabel()
    headerLabel.text = NSLocalizedString("List of things:", comment: "")
    headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
    headerLabel.textAlignment = .center
    headerLabel.textColor = UIColor.systemBlue
    return headerLabel
  }
  
  private func makeTableView() -> UITableView {
    let tableView = UITableView()
    tableView.dataSource = self
    tableView.delegate = self
    // more semi-arbitrary table view configuration
    tableView.separatorStyle = .none
    tableView.showsVerticalScrollIndicator = false
    return tableView
  }
  
  private func makeContinueButton() -> UIButton {
    let continueButton = UIButton()
    continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
    // `self` is available inside `lazy var` method calls!
    continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
    return continueBUtton
  }
}

The main advantage of this approach is that rarely-touched view creation code is both in a predictable place, and completely out of the way if you're browsing lots of files quickly. A bonus is that FUOs are not necessary due to the use of lazy var. And the factory method return types enable you to remove the explicit types from the property declarations.

That's all, thanks for reading!

In the middle of 2020, I was inspired to take everything I had learned from working on Literally Canvas and Buildy, and make a multi-user online whiteboard. The result is Browserboard, and I've been improving it regularly for the past year and a half.

screenshot

If you want to read more about it, check out the Browserboard Blog.

Yesterday, I added PNG export to Browserboard, my multiplayer whiteboard web app. About half the effort was spent getting text to render correctly. 90% of what I found about this topic on Google was garbage, especially on Stack Overflow, so here's my attempt at clearing things up for people who want to do this without relying on somebody's half-baked code snippet.

HTML Canvas has not changed in 18 years

Apple created the <canvas> element to enable Mac developers to create widgets for Dashboard, a feature that died in 2019. Canvas replicates a subset of the Core Graphics C API in JavaScript. Naturally, this hack intended for a proprietary OS feature became the foundation of thousands of browser games and the only browser-native way to generate PNG images from JavaScript.

Because <canvas> is based primarily on a macOS graphics API and not the web, it was not designed with great web support in mind. In particular, its text rendering capabilities are extremely poor. Some issues include:

  1. Line breaks are ignored.
  2. Only one font and style can be used. Multiple styles are not possible.
  3. Text will not wrap automatically. There is a “max width” argument, but it stretches the text instead of wrapping.
  4. Only pixel-based font and line sizes are supported.

So the best we can hope for in “multi-line text support in <canvas>” is to support line breaks, text wrapping, and a single font style. Supporting right-to-left languages is an exercise for the reader.

There is a good JavaScript library for drawing multi-line text in <canvas>

There's a library called Canvas-Txt that is as good as it gets, but for some reason doesn't rise above the blog trash on Google.

If you got here just trying to figure out how to accomplish this one task, there is your answer. You can stop here.

Deriving <canvas> text styles from the HTML DOM

For Browserboard's PNG export, I needed a way to configure Canvas-Txt to match my quill.js contenteditable text editor. The key to doing that is CSSStyleDeclaration.getPropertyValue(), which you can use to find out any CSS property value from its computed style.

The TypeScript code snippet below finds the first leaf node in a DOM element and applies its styles to Canvas-Txt. (If you're using JavaScript, you can just delete all the type declarations and it should work.)

import canvasTxt from "canvas-txt";

function findLeafNodeStyle(ancestor: Element): CSSStyleDeclaration {
  let nextAncestor = ancestor;
  while (nextAncestor.children.length) {
    nextAncestor = nextAncestor.children[0];
  }
  return window.getComputedStyle(nextAncestor, null);
}

function renderText(
  element: HTMLElement,
  canvas: HTMLCanvasElement,
  x: number,
  y: number,
  maxWidth: number = Number.MAX_SAFE_INTEGER,
  maxHeight: number = Number.MAX_SAFE_INTEGER
) {
  const ctx = canvas.getContext("2d");
  if (!ctx) return canvas;

  // const format = this.quill.getFormat(0, 1);
  const style = findLeafNodeStyle(element);

  ctx.font = style.getPropertyValue("font");
  ctx.fillStyle = style.getPropertyValue("color");

  canvasTxt.vAlign = "top";
  canvasTxt.fontStyle = style.getPropertyValue("font-style");
  canvasTxt.fontVariant = style.getPropertyValue("font-variant");
  canvasTxt.fontWeight = style.getPropertyValue("font-weight");
  canvasTxt.font = style.getPropertyValue("font-family");
  // This is a hack that assumes you use pixel-based line heights.
  // If you're rendering at something besides 1x, you'll need to multiply this.
  canvasTxt.lineHeight = parseFloat(style.getPropertyValue("line-height"));
  // This is a hack that assumes you use pixel-based font sizes.
  // If you're rendering at something besides 1x, you'll need to multiply this.
  canvasTxt.fontSize = parseFloat(style.getPropertyValue("font-size"));

  // you could probably just assign the value directly, but in TypeScript
  // we try to explicitly handle every possible case.
  switch (style.getPropertyValue("text-align")) {
    case "left":
      canvasTxt.align = "left";
      break;
    case "right":
      canvasTxt.align = "right";
      break;
    case "center":
      canvasTxt.align = "center";
      break;
    case "start":
      canvasTxt.align = "left";
      break;
    case "end":
      canvasTxt.align = "right";
      break;
    default:
      canvasTxt.align = "left";
      break;
  }

  canvasTxt.drawText(
    ctx,
    this.quill.getText(),
    x,
    y,
    maxWidth,
    maxHeight
  );
}

So there you go. Good luck.

This is the fifth post in a series about my new app Oscillator Drum Jams. Start here in Part 1.

You can download Oscillator Drum Jams at oscillatordrums.com.

Earlier this year I learned that Garageband on my iPhone can do multitrack recording when I plug it into my 16-channel USB audio interface. This is an object less than 6 inches long handling tasks that would have required thousands of dollars of equipment twenty years ago, accessible to most teenagers today.

The audio system running on iPhones was designed in 2003 for desktop Macs running at around 800 MHz, slightly slower than the original iPhone’s processor. It’s a complex system, but the high level APIs are consistent and well-documented. As a result, there are many fantastic audio-related apps on the store: synthesizers, metronomes, music players, and toys that make music creation accessible to anyone. And because there’s a USB audio standard shared between iOS and macOS, there’s no need to install drivers.

I’m really grateful that I’m able to build on the work of past engineers to make Oscillator Drum Jams. It wasn’t easy, but I was ultimately able to ship it because the pieces already exist and can be plugged together by a single person working on occasional nights and weekends.

I’m also grateful that I got the opportunity to work on this project with Jake, whose passion and dedication to his music meant that we had over a hundred loops to share with the drum students of the world.

This is the fourth post in a series about my new app Oscillator Drum Jams. Start here in Part 1.

You can download Oscillator Drum Jams at oscillatordrums.com.

This will be a shallower post than the others in this series. I just want to point out a few things.

The original UI was backwards

When I started this project, I thought about it like a programmer: as a view of a collection of data. So I naturally created a hierarchical interface: pages contain exercises, and exercises have a bunch of stuff in them. I worked really hard on an “exercise card” that would slide up from the bottom of the screen and could be swiped up to show more detail or swiped down to be hidden.

Screenshot of old design

After an embarrassing amount of time, I realized I was optimizing for the wrong thing. Really, I was optimizing for nothing. I finally asked myself what people would want to do while using this app. My speculative answers were, in order of frequency:

  1. Stop and start loops
  2. Adjust the tempo
  3. Go to another exercise within the same page
  4. Go to another page

With that insight—and no user research, so never hire me as a PM—I made some wireframes:

Wireframe 1

Wireframe 2

I shed a single tear for my “wasted” work and spent the next couple of weekends replacing all of my UI code.

Although the iPad wireframe was still a bit silly, we ended up in a pretty good place. The important thing is the play/pause button is nice and big. At some point I expect to rearrange all the controls on iPad, though, because the arrangement doesn't have any organizing principle to it.

(It does look much better than it could have due to the efforts of designer Hannah Lipking!)

Final screenshot of iPhone app

Final screenshot of iPad app

AutoLayout is a pain

I did this whole project using nothing but NSLayoutConstraint for layout, and I regret it. Cartography or FlexLayout would have saved me a lot of time and bugs.

Continue to Part 5: Coda

This is the second post in a series about my new app Oscillator Drum Jams. Start here in Part 1.

You can download Oscillator Drum Jams at oscillatordrums.com.

With my audio assets in place, I started work on a proof of concept audio player and metronome.

The audio player in Oscillator has three requirements: 1. It must support multiple audio streams playing exactly in sync. 2. It must loop perfectly. 3. It must include a metronome that matches the audio streams at any tempo.

Making the audio player work involved solving a bunch of really easy problems and one really hard problem. I’m going to gloss over lots of detail in this post because I get a headache just thinking about it.

AudioKit

I used AudioKit, a Swift wrapper on top of Core Audio with lots of nice classes and utilities. My computer audio processing skills are above average but unsophisticated, and using AudioKit might have saved me time.

I say “might have saved me time” because using AudioKit also cost me time. They changed their public APIs several times in minor version bumps over the two years I worked on this project, and the documentation about the changes was consistently poor. I figured things out eventually by experimenting and reading the source code, but I wonder if I would have had an easier time learning Core Audio myself instead of dealing with a feature-rich framework that loves rapid change and hates documentation.

Time stretching is easy unless you want a metronome

Playing a bunch of audio tracks simultaneously and adjusting their speed is simple. Create a bunch of audio players, set them to loop, and add a time pitch that changes their speed and length without affecting their pitch.

My first attempt for adding a metronome to these tracks was to keep doing more of the same: record the metronome to an audio track with the same length as the music tracks and play them simultaneously.

This syncs up perfectly, but sounds horrible when you play it faster or slower than the tempo it was recorded at. This is because each tick of a metronome is supposed to be a sharp transient. If you shorten the metronome loop track, each metronome tick becomes shorter, and because the algorithm can’t preserve all the information accurately, it gets distorted and harder to hear. If you lengthen the metronome loop track, the peak of the metronome’s attack is stretched out, so the listener can’t hear a distinct “tick” that tells them exactly when the beat occurs.

My first solution to this was to use AudioKit’s built-in AKMetronome class. This almost worked, but because it was synchronized to beats-per-minute rather than the sample length of the music tracks, it would drift over time due to tiny discrepancies in the number of audio ticks between the two.

My second, third, and fourth solutions were increasingly hacky variations on my first solution.

My fifth and successful metronome approach was to use a MIDI sequencer that triggers a callback function on each beat. On the first beat, the music loops are all be triggered simultaneously, and a metronome beat is played. On subsequent beats, just the metronome is played.

Metronome timing is hard

With a metronome that never drifted, I still had an issue: the metronome would consistently play too late when the music was sped up, and too early when the music was slowed down.

The reason is obvious when you look at the waveforms:

Illustration of waveforms  The peak of each waveform doesn't match exactly with the mathematical location of each beat, because each instrument’s note has an attack time between the start of the beat and the peak of the waveform. When we slow down a loop, the attack time increases, but the metronome attack time is the same, so the music starts to sound “late” relative to the metronome. If we speed it up, the attack time decreases, and it starts to sound “early.”

To get around this, I did some hand-wavey math that nudges the metronome forward or backward in time relative to the time pitch adjustment applied to the music tracks.

This approach uses the CPU in real time, which adds risk of timing problems when the system is under load, but in practice it seems to work fine.

Continue to Part 4: The Interface

This is the second post in a series about my new app Oscillator Drum Jams. Start here in Part 1.

You can download Oscillator Drum Jams at oscillatordrums.com.

To start making this app, I couldn’t just fire up Xcode and get to work. The raw materials were (1) a PDF ebook, and (2) a Dropbox folder full of single-instrument AIFF tracks exported from Jake’s Ableton sessions. Neither of those things could ship in the app as-is; I needed compressed audio tracks, icons for each track representing the instrument, and the single phrase of sheet music for every individual exercise.

Screenshot of Oscillator with controls for each track

Processing the audio

Each music loop has multiple instruments plus a drum reference that follows the sheet music. We wanted to isolate them so people could turn them on and off at will, so each exercise has 3-6 audio files meant to be played simultaneously.

Jake made the loops in Ableton, a live performance and recording tool, and its data isn’t something you can just play back on any computer, much less an iPhone. So Jake had to export all the exercises by hand in Ableton’s interface.

Ableton Live

We had to work out a system that would minimize his time spent clicking buttons in Ableton’s export interface, and minimize my time massaging the output for use in the app. Without a workflow that minimizes human typing, it’s too easy to introduce mistakes.

The system we settled on looked like this:

p12/
    #7/
       GUITAR.aif
       BASS.aif
       Drum Guide.aif
       Metronome.aif
    #9/
       BASS.aif
       Drum Guide.aif
       GUITAR.aif
       Metronome.aif

p36 50BPM triplet click/
                        #16/
                           Metronome.aif
                           GUITAR.aif
                           BASS.aif
                           RHODES.aif
                           MISC.aif
                           Drum Guide.aif

The outermost folder contains the page number. Each folder inside a page folder contains audio loops for a single exercise. The page or the exercise folder name may contain a tempo (“50BPM”) and/or a time signature note (“triplet click”, “7/8”). This notation is pretty ad hoc, but we only needed to handle a few cases. We changed the notation a couple of times, so there were a couple more conventions that work the same way with slight differences.

I wrote a simple Python script to walk the directory, read all that messy human-entered data using regular expressions, and output a JSON file with a well-defined schema for the app to read. I wanted to keep the iOS code simple, so all the technical debt related to multiple naming schemes lives in that Python script.

The audio needed another step: conversion to a smaller format. AIFF, FLAC, or WAV files are “lossless,” meaning they contain 100% of the original data, but none of those formats can be made small enough to ship in an app. I’m talking gigabytes instead of megabytes. I needed to convert them to a “lossy” format, one that discards a little bit of fidelity but is much, much smaller.

I first tried converting them to MP3. This got the app down to about 200 MB, but suddenly the beautiful seamless audio tracks had stutters between each loop. When I looked into the problem, I learned that MP3 files often contain extra data at the end because of how the compression algorithm works, making seamless looping very complex. MP3 was off the table.

Fortunately, there are many other lossy audio formats supported on iOS, and M4A/MPEG-4 has perfect looping behavior.

Finally, because Jake’s Ableton session sometimes contains unused instruments, I needed to delete files that contained only silence. This saved Jake a lot of time toggling things on and off during the export process. I asked FFmpeg to find all periods of silence in a file, and if a file had exactly one period of silence exactly as long as the track, I could safely delete the file.

Here’s how you find the silences in a file using FFmpeg:

ffmpeg
  -i <PATH>
  -nostdin
  -loglevel 32
  -af silencedetect=noise=\(-90.0dB):d=\(0.25)
  -f null
  -

Here’s how the audio pipeline ended up working once I had worked out all the kinks: 1. Loop over all the lossless AIFF audio files in the source folder. 2. Figure out if a file is silent. Skip it if it is. 3. Convert the AIFF file to M4A and put it in the destination folder under the same path. 4. Look at all the file names in the destination folder and output a JSON file listing the details for all pages and exercises.

Creating the images

The exercise images were part of typeset sheet music like this:

Sheet music

There were enough edge cases that I never considered automating the identification of exercises in a page, but I also never considered doing it by hand in an image editor either. No, I am a programmer, and I would rather spend 4 hours writing a program to solve the problem than spending 4 hours solving the problem by hand!

I started by using Imagemagick to convert the PDF into PNGs. Then I wrote a single-HTML-file “web app” that could use JavaScript to display each page of sheet music, with a red rectangle following my mouse. The JavaScript code assigned keys 1-9 to different rectangle shapes, so pressing a key would change the size of the rectangle. When I clicked, the rectangle would “stick” and I could add another one. The points were all stored as fractions of the width and height of the page, in case I decided to change the PPI (pixels per inch) of the PNG export. I’m glad I made that choice because I tweaked the PPI two or three times before shipping.

Here’s what that looked like to use:

Red rectangles around sheet music

The positions of all the rectangles on each page were stored in Safari’s local storage as JSON, and when I finished, I simply copied the value from Safari’s developer tools and pasted it into a text file.

Now that I had a JSON file containing the positions of every exercise on every page, I could write another Python script using Pillow to crop all the individual exercise images out of each page PNG.

But that wasn’t enough. The trouble with hand-crafted data is you get hand-crafted inconsistencies! Each exercise image had a slightly different amount of whitespace on each side. So I added some code to my image trimming script that would detect how much whitespace was around each exercise image, remove it, and then add back exactly 20 pixels of whitespace on each side.

I still wish I had found a way to remove the number in the upper left corner, but at the end of the day I had to ship.

Diagrams of the asset pipeline

Ableton session → Jake exports to AIFF → Dropbox folder of AIFF files, some all silence → Python script → m4a files omitting silent ones → Python script → JSON manifest

Book PDF → convert to PNG → Steve adds rectangles using special tool → Python script → exercise PNGs

Continue to Part 3: The Audio Player

For the past two years, I’ve been slowly working with my drum teacher Jake Wood on an interactive iOS companion to Oscillator, his drum book for beginner. The app is called Oscillator Drum Jams, and it’s out now!

Jake wrote almost 150 music loops tailored to individual exercises in the book. The app lets you view the sheet music for each exercise and play the corresponding loop at a range of tempos.

Instead of practicing all week to a dry metronome, or spending time making loops in a music app like Figure, students can sit down with nothing but their phone and have all the tools they need be productive and engaged.

The app supports all iPhone and iPad models that can run iOS 11, in portrait or landscape mode.

This project ties together a lot of skills, and I’m going to unpack them in a series of posts following this one.

If you enjoy this series, you might also want to check out my procedural guitar pedal generator.

Hipmunk, company that defined a large part of my career, is shutting down in seven days. This is a rambly post about it that I'm optimizing for timeliness over quality.

I joined in 2015 after leaving a failed startup, hoping to find out what life was like as an iOS developer while making travel search slightly nicer for ordinary people. Over the next two and a half years, I evolved from an overeager young engineer to a trusted engineering leader and manager, which enabled me to ultimately move on to Asana as the manager of the iOS team.

Hipmunk was full of people who wanted to make finding flights and hotels just a little bit easier. The entire value proposition was the user experience, and as an engineer, it was very rewarding to have almost all my work be in service of making a common experience a little bit nicer. There were lots of passionate people who were friendly, fun to be around, and good at their jobs.

I'm not the best qualified person to diagnose Hipmunk's failure, but from a business standpoint it was never exactly “crushing it.” Margins are thin for sales lead middlemen (i.e. metasearch sites), and airlines are complete bastards when it comes to their data. A lot of Hipmunk's revenue was coming from Yahoo Travel when I joined, and it shut down in 2016, which made things even tougher.

Users were hard to hang onto. We had to recapture them every time they decided to start searching for flights or hotels. The features we built to keep users around regularly all failed to move the needle, and half the time the people using our search wouldn't click our booking links even though they ended up buying the flights, so we wouldn't get paid.

Hipmunk made some technical decisions that in my experience made development much more challenging than necessary. Our home-grown ORM was poorly understood, our database usage was weird and badly optimized, and we had multiple huge rewrites of web code in React + Redux without understanding best practices, leaving messes for future engineers to clean up under pressure to ship even more changes.

There were some engineering bright spots. Hipmunk's in-house analytics platform was expensive to maintain but extremely accessible to everyone at the company and used by most people in all departments. Hackathons were approached with genuine passion, and many projects shipped, some even being integrated into the product strategy. Here's a video of one of mine:

The program shown in the video is completely functional and you can really book flights and hotels using w3m. The code came together really quickly because I had already rewritten mobile web flight search singlehandedly, as well as writing lots of hotel search code on iOS.

Hipmunk had a ritual of “demo time” every Friday afternoon, where anyone could show the rest of us what they had been working on. I like showing my work and I did a lot of getting-pixels-on-the-screen, so I gained a reputation for being Demo Guy. I liked being Demo Guy and I miss it.

On the product side, the bag was similarly mixed. We wasted thousands of engineer hours building things that never got traction and never made money. We had a full time team of 3+ working on a chat bot for some reason. Meanwhile, as the team shrank first after layoffs and then after the Concur acquisition, we didn't have enough engineers to address tech debt or even maintain existing systems. And honestly, in hindsight, I'm not even sure we should have had native mobile apps.

That said, I did get to do a lot of things I'm proud of as an engineer, alongside Jesus Fernandez, Wojtek Szygiol, Cameron Askew, and Ivan Tse, plus wonderful designers Lauren Porter and Tony Dhimes, and PM Devin Ruppenstein: * Built the best reusable, configurable calendar picker the world has ever seen * Designed an interesting and practical interview question that I used to hire an excellent engineer * Designed a scalable, clean, teachable iOS app architecture and slowly migrated the whole app to it * Rewrote the entire mobile web flight search site by myself to have a better user experience and use a faster API * Shipped the mobile web flight search site as part of the native app using a wrapper so clever you can barely tell it's secretly a web page

I made all of this:

Could Hipmunk have worked as a business under slightly different circumstances or with a different set of product decisions? Maybe. I'm not sure. Maybe with a small, bootstrapped team, but even then I wouldn't bet on it. The margins are too tight and the users aren't loyal, for good reason—all they want is a good price on a good flight or hotel.

Speaking of hotels, though, I invite you to look at Hipmunk Hotels:

Hipmunk Hotels

And then look at Google Hotels around November 2018:

Google Hotels

And then look at Steve Vargas's LinkedIn profile. 🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔