About this blog

At the start of this year I decided to have more of a public writing habit. I like it. I have no high literary ambitions here or anything; I see this site as a place for conversation with myself, fleeting thoughts, seeds for future enterprises, and most of all, Learning in Public. Check out the archive for a complete list of posts.

On the other hand, my very new newsletter Cranked Organ is meant for more intentional (though no less exploratory) writing. I don’t completely know yet what it should be about. Some of what I write in this blog might find its way there.

Building an RSS reader for Android #4: Data access objects and SQL queries

Picking up where I previously left off, a DAO is an abstract class or interface that contains definitions of methods for interacting with our database. Collectively the methods can be described as CRUD—Create, Read, Update, and Delete, which together comprise the standard four operations of a persistent storage system. Furthermore we don’t actually implement the methods ourselves; the Room library does this for us, although depending on the complexity of a given operation, some SQL (structured query language) is necessary.

To keep things organized, I created a few separate DAOs, three of them corresponding to the three tables or entities we defined previously (Feed, Entry, and FeedEntryCrossRef). For example, FeedsDao contains all operations that have to do with Feeds:

interface FeedsDao {

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    fun addFeeds(vararg feed: Feed)

    @Query("SELECT * FROM Feed WHERE url = :feedId")
    fun getFeed(feedId: String): LiveData<Feed?>

    @Query("SELECT url, title, imageUrl, category, unreadCount FROM Feed")
    fun getFeedsLight(): LiveData<List<FeedLight>>

    @Query("SELECT url, title, website, imageUrl, description, category FROM Feed")
    fun getFeedsManageable(): LiveData<List<FeedManageable>>

    @Query("SELECT url FROM Feed")
    fun getFeedIds(): LiveData<List<String>>

    @Query("SELECT url, category FROM Feed")
    fun getFeedIdsWithCategories(): LiveData<List<FeedIdWithCategory>>

    @Query("SELECT url FROM Feed")
    fun getFeedUrlsSynchronously(): List<String>

    @Query("SELECT title FROM Feed WHERE url = :feedId")
    fun getFeedTitleSynchronously(feedId: String): String

    @Update
    fun updateFeed(feed: Feed)

    @Query("UPDATE Feed SET title = :title WHERE url = :feedId")
    fun updateFeedTitle(feedId: String, title: String)

    @Query("UPDATE Feed SET category = :category WHERE url IN (:feedId)")
    fun updateFeedCategory(vararg feedId: String, category: String)

    @Transaction
    fun updateFeedTitleAndCategory(feedId: String, title: String, category: String) {
        updateFeedTitle(feedId, title)
        updateFeedCategory(feedId, category = category)
    }

    @Query("UPDATE Feed SET imageUrl = :feedImage WHERE url = :feedId")
    fun updateFeedImage(feedId: String, feedImage: String)

    @Query("UPDATE Feed SET unreadCount = :count WHERE url = :feedId")
    fun updateFeedUnreadCount(feedId: String, count: Int)

    @Query("UPDATE Feed SET unreadCount = (unreadCount + :addend) WHERE url = :feedId")
    fun incrementFeedUnreadCount(feedId: String, addend: Int)

    @Query(
        "UPDATE Feed SET unreadCount = (unreadCount + :addend) WHERE url IN " +
            "(SELECT url FROM FeedEntryCrossRef AS _junction " +
            "INNER JOIN Feed ON (_junction.feedUrl = Feed.url) " +
            "WHERE _junction.entryUrl = (:entryId))"
    )
    fun incrementFeedUnreadCountByEntry(entryId: String, addend: Int)

    @Query("DELETE FROM Feed WHERE url IN (:feedId)")
    fun deleteFeeds(vararg feedId: String)
}

How an operation gets defined within this interface is determined by a few things: 1) what specific data we want retrieved, if any, 2) what data we have on hand by which to fetch something else (for example, primary keys, or any other property), and 3) whether the data should be fetched synchronously or asynchronously—I’ll come back to this point later.

Simple operations

The simplest operations are those that don’t return any data. For instance, addFeeds accepts a variable number of arguments (vararg), that is, any number of Feeds that we want added to our database. We simply annotate it with @Insert and Room itself creates the necessary database query. We also include OnConflictStrategy.IGNORE to tell Room to ignore any incoming Feed whose primary key (in this case, its URL) already exists in our database.

@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addFeeds(vararg feed: Feed)

Another simple operation is updateFeed, which takes as an argument a single Feed. By simply annotating it with @Update, Room knows to use the Feed’s primary key to query the database and make the necessary changes.

@Update
fun updateFeed(feed: Feed)

SQL queries, and synchronous vs asynchronous operations

In most other cases, we need to write the SQL queries ourselves. SQL is a standard way of communicating with relational databases. For example, the method getFeed, which takes a single feedId (or its URL) as an argument, is annotated with @Query, into which we pass a simple query as a string: SELECT * FROM Feed WHERE url = :feedId. This means we want to select all (*) columns from all rows in our Feed table whose value of url is equal to the feedId that has been passed into the method. Because we’ve ensured that all Feed URLs in our table are unique, we expect to find only one Feed.

@Query("SELECT * FROM Feed WHERE url = :feedId")
fun getFeed(feedId: String): LiveData<Feed?>

Aside: the above method returns to us a nullable Feed wrapped in a LiveData object. Most of the methods defined in this database are asynchronous; this is because database transactions require a perceivable amount time to complete, much like an HTTP request. So as to not make our user interface wait so much, a LiveData object is returned instantly without its content. This LiveData is observable by our UI; as soon as the transaction completes, its content is updated automatically.

There are indeed cases where we want to fetch data synchronously, such as in background tasks where the user interface is not involved. Here we simply skip using LiveData:

@Query("SELECT url FROM Feed")
fun getFeedUrlsSynchronously(): List<String>

@Query("SELECT title FROM Feed WHERE url = :feedId")
fun getFeedTitleSynchronously(feedId: String): String

Because database transactions are expensive, in many cases we need to define our database operations so that we only fetch precisely the data that we need and nothing more. In the above two methods, for example, each one returns only one field from the Feed table. For cases where we need multiple fields, I created different variations of our Feed data class, each with a different set of properties depending on our needs.

// Light version of Feed – no website and description
data class FeedLight(
    val url: String,
    var title: String,
    val imageUrl: String?,
    var category: String,
    var unreadCount: Int
)

// Feed without unreadCount
data class FeedManageable(
    val url: String,
    var title: String,
    val website: String,
    val imageUrl: String?,
    val description: String?,
    var category: String
): Serializable

data class FeedIdWithCategory(
    val url: String,
    val category: String
)

The following methods show how the above data classes come into play. In our queries, we specify which fields we want in particular, and Room does the job of mapping them onto our specified return objects. In the case of getFeedIds, we only care about one field, url, which is a String. Note also that each of these methods returns a list of objects (wrapped in LiveData), exactly what we expect from a query that ends with FROM Feed without qualifiers—meaning it will fetch data from all rows of our Feed table.

@Query("SELECT url, title, imageUrl, category, unreadCount FROM Feed")
fun getFeedsLight(): LiveData<List<FeedLight>>

@Query("SELECT url, title, website, imageUrl, description, category FROM Feed")
fun getFeedsManageable(): LiveData<List<FeedManageable>>

@Query("SELECT url FROM Feed")
fun getFeedIds(): LiveData<List<String>>

@Query("SELECT url, category FROM Feed")
fun getFeedIdsWithCategories(): LiveData<List<FeedIdWithCategory>>

Making queries with limited data

Earlier I mentioned updateFeed, which is an operation made simple for us to define and annotate because it accepts an entire Feed as an argument. There’s no need to write an explicit SQL query because Room has all the information it needs from a complete Feed object. But there are many cases in which we might want to communicate with our database by passing in only a limited amount of data.

For example, updateFeedTitle accepts only a feedId (or URL) and a new title as arguments, so we include a query: UPDATE Feed SET title = :title WHERE url = :feedId. This means it will update a Feed’s title column with the given title, in any row where the Feed’s url column is the same as the given feedId.

@Query("UPDATE Feed SET title = :title WHERE url = :feedId")
fun updateFeedTitle(feedId: String, title: String)

The method updateFeedCategory is similar, except it accepts a variable quantity of feedId (URLs) and exactly one category. This means that it will update a Feed’s category column with the given category, in each row in which the feedId is found among the given collection of feedId’s.

@Query("UPDATE Feed SET category = :category WHERE url IN (:feedId)")
fun updateFeedCategory(vararg feedId: String, category: String)

Combined methods

Methods may also be combined into a single transaction with the @Transaction annotation, in cases where we want them executed at the same time.

@Transaction
fun updateFeedTitleAndCategory(feedId: String, title: String, category: String) {
    updateFeedTitle(feedId, title)
    updateFeedCategory(feedId, category = category)
}

Complex queries

The method incrementFeedUnreadCount, as its name suggests, is meant to increment a Feed’s unreadCount property by any given number, which could be positive or negative. It simply takes a feedId as an argument, along with a desired addend, and finds the desired Feed to which the given feedId belongs.

@Query("UPDATE Feed SET unreadCount = (unreadCount + :addend) WHERE url = :feedId")
fun incrementFeedUnreadCount(feedId: String, addend: Int)

But what if we wanted to perform the same operation by passing in an entryId instead of a feedId? Remember that Feed and Entry have a many-to-many relationship; this means we want the operation to apply to all Feeds that are associated with any given Entry. In such a case, we need to consider related data across different tables, which requires a more complex query.

So, we annotate the method incrementFeedUnreadCountByEntry with a nested query: UPDATE Feed SET unreadCount = (unreadCount + :addend) WHERE url IN (SELECT url FROM FeedEntryCrossRef AS _junction INNER JOIN Feed ON (_junction.feedUrl = Feed.url) WHERE _junction.entryUrl = (:entryId)).

First, let’s look at the inner query: this time, we select a Feed’s url property from our FeedEntryCrossRef table, using it as a junction (and, appropriately, giving it the variable name _junction) between our Feed and Entry entities. The INNER JOIN clause allows us to join the Feed table to our FeedEntryCrossRef table, by establishing an equivalence between Feed.url and _junction.feedUrl. In the resulting joined table, the query selects a Feed’s url field from all rows in which the value of entryUrl is equivalent to the given entryId, giving us a collection of one or more Feed URLs.

From here the outer query is made much simpler: it looks for all rows of Feed in which the value of url is present in the above collection, and adds the given addend (which, again, may be positive or negative) to the existing value of unreadCount.

@Query(
        "UPDATE Feed SET unreadCount = (unreadCount + :addend) WHERE url IN " +
            "(SELECT url FROM FeedEntryCrossRef AS _junction " +
            "INNER JOIN Feed ON (_junction.feedUrl = Feed.url) " +
            "WHERE _junction.entryUrl = (:entryId))"
    )
fun incrementFeedUnreadCountByEntry(entryId: String, addend: Int)

All of the above represent database operations that are relevant only to Feeds, though in one instance we have needed to consider our FeedEntryCrossRef table. As I mentioned before, there are other DAOs in the app—I’ll write about them next time.

Building an RSS Reader for Android #3: Setting up a relational database, many-to-many relationships, and Room

Previously in this series, I wrote about FeedParser and FeedSearcher, each representing a data source for the app. The former is responsible for fetching and parsing RSS feeds, while the latter is responsible for fetching a collection of information about RSS feeds that can be subscribed to. To clarify, they are not true data sources, since they don’t store or provide data themselves; each one is simply a layer meant to access data from somewhere else, although the rest of the app has no knowledge of this. In both cases, the actual data source is remote: for FeedParser, raw XML pages from the web; and for FeedSearcher, Feedly’s search engine.

So we have a way of fetching basic information about an RSS feed (its URL, title, description, etc.), and a way to to actually retrieve the contents of the feed itself, including a collection of entries. But to actually enable subscription to a feed, the app needs a third, local data source that it can use to access previously parsed feeds—otherwise, to read a feed that we’ve already subscribed to, we would have to keep fetching and parsing it from the internet. This is where a local database comes in.

Room, entities, and many-to-many relationships

Room is Android’s standard library for local data persistence. According to the documentation, it “provides an abstraction layer over SQLite to allow fluent database access while harnessing the full power of SQLite.” This greatly reduces the amount of work required for database access, as opposed to using the SQLIte APIs directly, which is more error-prone and involves significant boilerplate code. (Read the documentation for more information.)

In my first post, I hinted that NiceFeed is concerned mainly with two kinds of data objects: Feed and Entry. We define these as data classes, specifying all the relevant properties, and annotating each of them with @Entity. This tells Room that each of these data classes represents a table. Additionally, for each entity we specify a @PrimaryKey, signifying that the annotated property is an item’s unique identifier; for Feed and Entry, each one’s primary key is its URL.

@Entity
data class Feed(
    @PrimaryKey val url: String, // Doubles as Feed ID
    var title: String,
    val website: String,
    val description: String? = null,
    val imageUrl: String? = null,
    var category: String = "Uncategorized",
    var unreadCount: Int
) : Serializable

@Entity
data class Entry(
    @PrimaryKey val url: String, // Doubles as Entry ID
    val title: String,
    val website: String,
    val author: String?,
    val date: Date?,
    val content: String?,
    val image: String?,
    var isStarred: Boolean = false,
    var isRead: Boolean = false
) : Serializable {

    ...
}

I also mentioned before that the relationship between Feed and Entry is many-to-many. This simply means that a Feed can be associated with multiple entries, which is usually the case. Occasionally, a single Entry will be part of more than one Feed. For example, an article from the New York Times may be included both in the Latest News and Travel feeds. This is important to note so that we don’t end up with duplicate articles in our database.

To create an association between one Feed and one Entry, we define a third data class and entity: FeedEntryCrossRef, which simply contains the URLs of one Feed and one Entry. This entity represents a junction by which two other tables can be joined; it tells us that that the Feed whose URL is stored here is associated with the Entry whose URL is also stored here. (Check out this tutorial on SQLite joins for more information.)

@Entity(
    primaryKeys = ["feedUrl", "entryUrl"],
    indices = [(Index(value = ["entryUrl"]))]
)
data class FeedEntryCrossRef(
    val feedUrl: String,
    val entryUrl: String
)

Defining the database

Now to create our database. We define NiceFeedDatabase as an abstract class which extends Room’s predefined RoomDatabase; this is because Room itself handles the building. Within the @Database annotation for Room, we pass in our three entities—Feed, Entry, and FeedEntryCrossRef—and mark it as version 1. And within the companion object, we define a static method for actually building the database, so that we can call on the class directly upon starting the app to initialize our database.

@Database(
    entities = [
        Feed::class,
        Entry::class,
        FeedEntryCrossRef::class
    ],
    version = 1
)
@TypeConverters(com.joshuacerdenia.android.nicefeed.data.local.database.TypeConverters::class)
abstract class NiceFeedDatabase : RoomDatabase() {

    abstract fun combinedDao(): CombinedDao

    companion object {

        private const val DATABASE_NAME = "database"

        fun build(context: Context): NiceFeedDatabase {
            return Room.databaseBuilder(
                context.applicationContext,
                NiceFeedDatabase::class.java,
                DATABASE_NAME
            ).build()
        }
    }
}

Type converters

Additionally, notice that there’s a second annotation, @TypeConverters. This is because SQLite only directly handles basic data types like strings and integers. But Room provides a way to easily convert others into types that SQLite will handle. Our Entry data class includes a Date, representing the date it was published. So we define a TypeConverters class to contain methods that tell Room what to do when it receives a Date to store into our database (fromDate), and when we ask for one (toDate). Then we pass this class into our @TypeConverters annotation above.

class TypeConverters {

    @TypeConverter
    fun fromDate(date: Date?): Long? {
        return date?.time
    }

    @TypeConverter
    fun toDate(millisSinceEpoch: Long?): Date? {
        return millisSinceEpoch?.let {
            Date(it)
        }
    }
}

Data access objects—to be continued

And with that, our database setup is nearly complete. The one final thing to note is the abstract function that I have named combinedDao. This represents our data access objects (or DAO, for short)—basically, an interface or intermediately layer through which our app can access data from our database. Within combinedDao (so named because it’s a combination of multiple smaller interfaces, each having to do with one of our three entities), I have defined all methods necessary for the app to get the data that it needs in various cases. NiceFeed is a mildly complex app, so I ended up needing a significant number of these methods, some involving complex SQL queries. I’ll write about this in a future post.

A eulogy

There are two different angles from which I want to muse, however briefly and poorly, on the recent death of a friend.

First: social media has made the experience of friendship, loss, and mourning weird in ways that I don’t yet know how to articulate. I don’t go on Facebook much, but about two weeks ago while scrolling my feed I noticed a post from my old friend Ariel. The post was pretty unremarkable, but it made me think of him for a moment.

I wondered what he was up to, what he’d been working on lately – it had been a few years since we last communicated. But social media gives us a sense that people in our lives are just there, anytime, waiting to be reached whenever you feel like it. So I continued scrolling my feed and instantly forgot about him. Today, I learned that he died a few days ago.

Second: our relationship went back about a decade and a half, when I first got myself involved in a production of a musical he wrote. I remember many conversations about music: learning it, pursuing it as a career, doing it as a side hustle, etc. A few years after that, we reconnected when he needed someone to help him record his musical.

We met up many times over several weeks to arrange and record 20 or so songs – he was very glad to get me on board at that time because I was a couple of months away from flying off to Singapore to study music. We had many more conversations about composing, piano playing, musicals, etc. Even after I left, we stayed loosely connected, and occasionally he’d get my help on something he was working on. I gave him advice about notation software.

He was a self-taught musician, which always took a backseat to his real job, and he picked my brain at every opportunity because I was the “real” composer. But I think he loved music more than I ever did, and was so humbled by it in a way that I don’t often see in people, which showed in how gently he always acted and spoke. I admired him for these reasons, and regret not getting his own advice. May his memory be eternal.

Startup explorations #25 / Building, slowly

I’m very thrilled to say this project has entered the building phase. This process will be very slow at first, but it’s off to a meaningful start. Actually, barely anything has been built: I wrote down a series of steps as to how I was finding tweets to recommend to my users, and had a back-end developer turn it into a simple program, which sits on top of the Twitter API.

The program takes a Twitter username, a list of keywords, and a desired maximum number of results; then it returns a list of recommended tweets for that particular user. Each tweet returned has some kind of visible engagement (at least 1 like, reply, or retweet), some relevance based on predefined keywords, or an invitation to respond, i.e., a question.

There isn’t a user interface yet; I simply use Postman to make a request. Yet this is a significant step forward because what used to take me 20 or 30 minutes for each user (without taking various distractions into account) now only takes a few seconds. This will enable me to run the next experiment much more efficiently, leaving room for more users, not to mention more room to experiment with various aspects of the project (see one of my previous posts about ideas for future improvement).

From here, apart from getting more users into our community, the main development task is to figure out how to keep improving this automation tool. It will still take a human mind to look through the results of each request and determine whether they are actually appropriate. There are still many other areas too that can be automated, such as finding keywords, determining the challenges to give each day, etc.

I’ve enjoyed this essay from Paul Graham about doing “things that don’t scale” and have been keeping it mind at all times. While the building process at this stage is slow, gradual, and iterative, it’s actually a great opportunity to engage all the more intently with my early users, include them in the process, and focus on meaningfully making a difference toward meeting their needs. I’m looking forward to running a third experiment very soon.

ADDENDUM: I’ve decided to conclude this blog series with this post. I’ve greatly enjoyed “learning in public” and intend to continue and ramp it up in other ways. As for the project itself, the way forward is still long and unclear, with different possible directions to take, but I will look back to all I’ve learned and have written here as a guide.

Great Lent

The particular draw of the more ancient forms of Christianity, to me, lies in imagery. In Eastern Orthodoxy, this is most evident in the use of icons. Every icon, or holy image, points to something higher—the saints, stories from the Bible, God himself. But it’s not just pictures and paintings; the Bible is itself a verbal icon, for instance. The liturgical seasons too are images.

The one year so far that I’ve been prevented from moving by the pandemic, and cut off from religious community, is now bookended by two Lenten seasons. I can’t help but dwell on the imagery: the great and holy fast, as the Orthodox call it, points to Christ’s time in the desert immediately after his baptism in the Jordan. Out there, he fasts for forty days and resists the devil’s temptations.

Without getting hung up too much on the presumptuous comparison, I’ve come to think of this whole time as my personal desert. It came after a year of spiritual abundance, despite thorny personal circumstances, as I prepared to join the Church. And then, almost immediately—this. My relationship to Orthodoxy has become tenuous as a result. I’m constantly noticing demons.

Many saints of old went long stretches of time without holy communion while living in the desert or in adverse conditions. Monastics have a calling, and, I suspect, a gift that empowers them to leave the world behind and do what they do.

Modern converts to Orthodoxy, especially those of a Western persuasion, tend to romanticize monastic life: there’s something about the combination of crosses, icons, beards, prayer ropes, and desert imagery—I’ve not been immune to the allure of these things. But there’s nothing romantic about this suburban desert.

The rest of us who are not monastics must be content to do our best in the world. Ironically my friendship with the world has improved significantly in the last year—materially, professionally, even psychologically. But I feel malnourished in the spirit and unable to do a thing about it but wait: forty days, four hundred, or more.

Startup explorations #24 / Reflection on audience building

Lately I have been reflecting a lot on the “journey” of audience building as it relates to this project I’ve been working on in the last couple of months. Now, the perspective I take on this subject is wholly my own, though I’m sure many people in my circle can relate—that is, that of a creator whose primary motivation is craft rather than making sure I’m noticed. In fact, much of the time, being noticed makes me uncomfortable.

I believe in the importance of listening to oneself carefully, no matter what one might find. There’s something about the words “audience,” “following,” etc. that still makes me recoil. Instead of dismissing this outright as a function of some undesirable quality that I have to snuff out—insecurity, unwillingness to get out of my comfort zone, or whatever—I’ve become very curious about it. And I find that much of it has to do with the images that these words conjure in the mind.

The images that come to my mind are celebrities, movie and pop stars, politicians, commentators, etc., all constantly under the prying eyes of their fans. On the internet, it’s blue-checks and high-follower accounts and their hordes of mostly pseudonymous followers constantly hanging on to their every word. There’s great power in having such an audience, but it’s also a liability—they have a cost to maintain, which is keeping them happy with lowest-common-denominator content that will keep them coming back.

In my first newsletter, I responded to an article that came out on Rolling Stone about how Juilliard, a classical music conservatory, must embrace pop artists in order to make itself relevant to the times. There is some truth to that, but I was critical of the article because while it made appeals to innovation, all it could offer as models were superstars who make millions in ticket and album sales and win big awards.

In fact, rather than innovative, I find such thinking old-fashioned—a vestige from an older time. This older time was full of gatekeepers and middlemen who defined what success was (usually related to money and fame), and decided who deserved it. The solidification of the creator economy at present has made all of that obsolete; creators of today no longer need permission to create and distribute. With the ability to define their own careers comes the space for more personal ideas of success.

By extension we are free to define what “audience” means. I don’t like getting hung up on precise definitions. I think I’m most inclined toward how Daniel Vassallo described it in his Twitter course (to paraphrase): having an audience means having people who are interested in what you have to say. This is difficult to quantify (unlike one’s follower count) but you know it when you see it.

This also implies the right kind of people, depending on who you are. Consider what value it brings you if have thousands of followers attracted by banal content designed only to appeal to the largest number of people possible. Maybe to some people, only numbers matter—this is all well and good. But I think a creator to whom their craft is the primary consideration would be more discerning.

The beauty of our current moment is that independent creators are free to maintain niche interests that are true to themselves; it is no longer necessary to appeal to “mass taste,” like that Rolling Stone article suggests, only to find a select group of people with whom you resonate strongly. Still I don’t mean to suggest that numbers don’t matter entirely, only that it matters much more what those numbers are made up of.

As the creator economy grows, I wish to see it be more inclusive; many creators are not businesspeople, marketers, nor social media experts. On the other hand, a creator, by virtue of the nature of their work, is an agent of human connection. They have the power to touch, move, illuminate, charm, and entertain. This need not always happen at scale; on the contrary, when it happens on a personal level it’s much more powerful.

I say all these things as a matter of intuition rather than scientific fact. I’m an obscure person on this earth, going on the journey myself of building my audience and growing my personal community, both on and off the internet, and from very little. This project therefore is a quest of betting on the ideas I have.

One important caveat, based on observation: audience building as such cannot be used as a proxy for something else. If there is a hole in someone’s heart to which they think having an audience is the answer, well, it’s probably not. This is slow and long work, and if one’s sense of self is coupled tightly with the size of their audience, it can be the cause of some great dissatisfaction.

The most successful people in this area would attest to the importance of giving rather than taking. This means that one ought to have something to give in the first place—and cultivating that must take precedence. When that foundation is in place, one can feel secure, and the rest is play.

Startup explorations #23 / Game plan – Starting a newsletter

Obviously this blog series has begun to transition away from poking around for ideas and “startup explorations” per se to the early stages of actually building a product! This means I’m no longer working alone: now I have developers on hand working with me on 1) automating my tweet recommendation process, and 2) creating a landing page. I’m hoping that by the end of the month we’ll have something more tangible to share.

Not that the exploration is over—this will be a long process, involving building the software gradually and continually finding new users and going back to them for feedback. When this initial, early building phase is done I’ll be in a position to run a third experiment with more participants and over a longer time span.

I’m intrigued by the ethos of building in public and plan to continue documenting this project in its own dedicated site. Hence, I will conclude “startup explorations” here at the end of the month.

It also occurs to me that if I’m going to be in this problem space, I need to participate in it much more than I already do. So the time has come to graduate this blog series into something more public-facing: a newsletter. This will be my attempt at understanding the creator economy space, drawing on things I already know well (creation, the arts) and things I want to learn more about (technology, the startup ecosystem). Read it here.

Startup explorations #22 / Ideas for future improvements

This is a continuation of my last post about my thoughts after wrapping up my second Twitter experiment. Here are some things to consider in the future, based on feedback I’ve gotten:

More difficult challenges. So far, each day’s challenges are really only a combination of posting and/or commenting (at first I also asked my users to follow recommended accounts, but it didn’t seem that meaningful); I have never asked for more than 3 posts and 3 comments at any one time. With a longer timeframe than one week, I’d be able to include much more difficult challenges to keep things interesting.

Allowing users to set their own targets. For example, aiming for a longer-term target, such as +x% profile views or tweet impressions after y days, and adjusting daily challenges progressively toward said target.

A time limit for each day’s challenge. I have been hearing from some of my users that this experiment has pushed them to engage with content they wouldn’t have on their own, and that they’re spending more time being thoughtful about what to post. This is good, especially if we want meaningful engagement, rather than just empty metrics. At the same time, I also don’t want them spending too much time. Work fills up whatever time you allow for it; perhaps a time limit would solve this.

Ebbs and flows. I have mentioned this before. A straight, progressive series of challenges works well over a short time frame like 7 days. Over a longer period, it would make more sense to lighten up on some days, such as weekends. Perhaps users could be given the option of setting the number of days a week they want to be active.

Getting to know other participants. I thought this was an interesting suggestion; since this is about community building, why not include a way of letting participants interact with each other through the challenge? They would be able to compare progress, see what others are doing that works or doesn’t work, etc. The added element of competition may be encouraging to some, but probably not everyone.

More recommendations from outside a user’s network. I have been focusing on getting recommendations based entirely on a user’s past activity; introducing an element of randomness might make the experience more interesting and unpredictable.

Startup explorations #21 / Wrapping up Experiment No. 2, and looking ahead

I had even more fun doing this experiment a second time; the feedback continues to be encouraging, and now I think I have enough information to keep going with the project and move on the next step.

It will remain important to keep running and iterating on this experiment, but now it seems appropriate to think about scaling up. Doing it manually, I have only been able to accommodate 3 – 4 users at a time so far. I’d like to be able to do the experiment with more people than that, and with a longer timeframe than 7 days; this means working to automate the recommendation/curation process somehow, which will save me a lot of time.

Over the last week, I settled on a precise series of steps that I followed as much as possible to find conversations to recommend to my users. Given that I observed that my users mostly followed the recommendations, I would say it’s a decent start. Contingent on access to Twitter data, a more experienced developer at our company should be able to translate it into an algorithm that takes any Twitter user as an input and returns a list of conversations relevant to that user.

The other side to all of this is marketing: who are our customers, what do they want, and why should they even care about what we’re trying to offer? The second experiment has given me a chance to get to know more users, from which to make a few generalizations, but this continues to be something for me to work on carefully. Hopefully, being able to iterate on the experiment while including a larger number of participants will help us further along this goal.

It has been highly enjoyable seeing this project sprout from nothing to what it is at the moment—still little more than a seed of an idea, but continually growing week by week. It can be difficult at any given moment to feel like I’m making any real progress, but with each new milestone reached the path becomes a little clearer.

Startup explorations #20 / Why am I doing this?

Two months and twenty posts into this project is as good a time as any to ask, why am I doing this? This is no project that will change the world or advance human civilization in any way. I can’t help thinking of the phrase, “stupid problems require stupid solutions”—and yet, that doesn’t mean it can’t be driven by a vision I have that speaks to things that I (and many others, I’m sure) truly care about.

This vision occurred to me today on the third day of our current experiment, as I was scrolling yet again through Twitter looking for conversations that my users might enjoy. I have been spending more time on Twitter than I’d like to admit doing this for my users, falling into rabbit holes here and there, as well as putting my own money where my mouth is by trying to step up my own engagement.

Twitter and social media in general offer great opportunities to any creator wishing to build an audience and willing to put in the time and energy to find their community. I’ve met many interesting people online who have made my life a little richer in these times of isolation. But all the same, I don’t really want a world where people are just scrolling their phones all day; I don’t want people spending a second longer than they have to to keep up with their communities. I want to save people (and myself) time to do far more interesting and important things: to create, and live their offline lives. This is what I want to accomplish.