エンジニア向けのチェックリストアプリ「Axions」を作った

チュートリアルで終わらせないタスク管理アプリづくり

  • SvelteKit
  • Lifelog

投稿日:

もくじ

はじめに

先日YouTubeチャンネル“No Boilerplate”の動画を観ていたところ、次のような一節に出会った。

『Hack Your Brain With Elaborate Coping Mechanisms』より
『Hack Your Brain With Elaborate Coping Mechanisms』より

Humanize
Do everything manually, on paper if needed, for many iterations.

Organize
Notice the patterns that are creeping into your methods.

Mechanize
Automate those patterns in a system, either by writing checklists, flowcharts or software.

No Boilerplate 『Hack Your Brain With Elaborate Coping Mechanisms』

要約すると「日常に潜む反復的な作業を洗い出し、チェックリストやフローチャートを作って自動化しよう」ということだ。

その言葉に触発されてソフトウェアエンジニア向けのチェックリストアプリ「Axions(アクションズ)」を作成したので、本稿では開発の動機や技術スタックなどについて記す。

動機

これが本稿で解説するWebアプリ「Axions」だ。名前からも分かる通り、UI/UXともにGitHub Actionsを模した設計になっている。

Axionsの実際の画面
Axionsの実際の画面

単にチェックリストを使いたいだけなら既存のプロダクトがいくらでもあるが、我々の目的は反復的な作業の定義と遂行だ。そう考えるとシンプルなチェックリストよりはこの目的に特化したソリューションがあったほうがよいだろう。そういった動機でAxionsの開発は始まった。

そうしてコンセプトメイキングをしているときに浮かんだのが「GitHub Actionsの人間版」というアイデアだった。GitHub ActionsなどのCI/CDツールは反復的な作業の自動化における終着点といえるし、我々ソフトウェアエンジニアはこれらのツールの概念や使い方に慣れ親しんでいる。それに、毎日使うアプリは使っていて楽しいデザインのほうがいい。(我ながら名案だ!)

以下に最終的なAxionsのコンセプトをまとめる。

  • ターゲット層: ソフトウェアエンジニア
  • ポジショニング: Humanize, Organize, Mechanizeというフローに特化したツール
  • オリジナリティ: CI/CDツールにインスパイアされたUI/UX
  • ベネフィット: 反復的な作業の定義と遂行の実現、確実化

(ちなみに:既存のプロダクトのデザインを盗用することには「ドキュメントを用意しなくて済む」というメリットもある。なぜならユーザーはテキストボックスやボタンを一目見るだけで、すぐさまそれぞれのコンポーネントの役割を理解できるからだ。筆者はこのような現象を「第二次スキューモーフィズム」と呼んでいる)

技術スタック

Axionsの開発に採用したフレームワーク等は以下の通りだ。

  • Webフレームワーク: SvelteKit
    • 選定理由: 筆者の既存プロジェクトでも何度かSvelteKitを採用しており、開発に慣れているため
    • デファクトスタンダードであるNext.jsも検討したが、JSX構文をまだ勉強中であることを理由に見送った
    • こういうその場の思い付きのプロダクトはモチベーションが続くうちに完成させなければならず、そのためには慣れたものを使いたかった
  • スタイリング: Tailwind CSS
    • 選定理由: 最近使い方を覚えて「こりゃいいや」と思ったため
    • クラス名さえ暗記してしまえば高速にUIを作れるので「急いで完成させる」という要件にも適合する
  • ホスティング: Cloudflare Pages
    • 選定理由: 安いから
    • 極貧学生という立場上、従量課金制のサービスを採用してしまうと(ありそうにない話だが)バズった矢先に「ユーザーが増えすぎたのでサ終します」という事態になりかねない
    • Push一発でGitHubからデプロイできるから変なスクリプトを書かなくて済むのもうれしい
  • データの永続化先: LocalStorage
    • 選定理由: 実装が楽だから
    • ユーザー登録機能を含めてしまうと認証やDBのためにBaaSを契約する必要があり、それによってよくわからんSDKの使い方を覚えたり料金の心配をすることになるのが嫌だった
    • TypeScriptの型を定義する以外にデータ構造を説明する行為をしたくなかったという理由もある

今回はとにかく開発スピードとコストの安さを意識して技術スタックを考えた。この組み合わせなら今後のことを心配せずにインターネットに放流しておけるだろう。

アーキテクチャ

ソースコードはGitHub上のパブリックリポジトリに置いてあるので詳しくはそちらを参照してほしいが、このセクションでは設計思想やアーキテクチャについてダイジェスト的に説明する。

データ構造

Axionsでは作業の構造をRoutine, Workflow, Stepの3つの段階に分けて表現している。以下にTypeScriptでの型定義を示す。

export type Step = {
    label: string
    done: boolean
}

export type Workflow = {
    label: string
    steps: Step[]
}

export type Routine = {
    id: string
    canonicalName: string
    workflows: Workflow[]
}

Routineは複数のWorkflowを持ち、Workflowは複数のStepを持つ。基本的には「朝のルーティン」「昼のルーティン」などを定義し、それぞれのルーティンに対して「朝食」「身だしなみ」などのワークフローを定義、最終的にそれぞれのワークフローに「コーヒーを淹れる」「パンを焼く」あるいは「髪を整える」「歯を磨く」などのステップを定義していく……という使い方を想定している。

削除ボタンの設計

削除ボタンはユーザーが誤って削除しないように、1秒間ボタンを押し続けることで操作を実行するという仕様になっている。

export const Utils = {
    onRemoveButtonClick(callback: () => void): number {
        const timeoutID = setTimeout(() => {
            callback()
        }, 1000)

        // tscがNode.js側の型定義を読んで`Nodejs.Timeout`を返すと勘違いするので、型アサーションでnumberに変換している
        return timeoutID as unknown as number
    },

    onRemoveButtonLeave(removeButtonTimeoutID: number | null, callback: () => void) {
        if (removeButtonTimeoutID) {
            clearTimeout(removeButtonTimeoutID)
        }
        callback()
    },
}

今見るとsetTimeoutにハードコードされた遅延時間を渡していたり、そもそも意味もなく削除ボタンから関数を分離していたりなどの問題はあるが、急いで書いたコードなので見逃してほしい。

また、コンポーネントの再利用性を高めるために削除ボタンはカリー化したコールバック関数をプロップとして受け取るようにしている。以下に実際のコードを掲載する。

<script lang="ts">
    import { Utils } from "$lib/utils"

    // ボタンが1秒間押されたときに実行する処理
    export let removeButtonClickCallback: () => void

    let removeButtonTimeoutID: number | null = null

    function createTimeOutIDClearer() {
        return () => {
            removeButtonTimeoutID = null
        }
    }
</script>

<!-- ちなみに、このボタンはなんと656文字もある -->
<button class="rounded-button block bg-[#21262d] border-[#363b42] text-[#f85149] active:bg-[#a40e26] active:border-[#911126] active:text-white {$$restProps.class ?? ''}" type="button" on:mousedown={() => (removeButtonTimeoutID = Utils.onRemoveButtonClick(removeButtonClickCallback))} on:touchstart={() => Utils.onRemoveButtonClick(removeButtonClickCallback)} on:mouseup={() => Utils.onRemoveButtonLeave(removeButtonTimeoutID, createTimeOutIDClearer())} on:mouseleave={() => Utils.onRemoveButtonLeave(removeButtonTimeoutID, createTimeOutIDClearer())} on:touchend={() => Utils.onRemoveButtonLeave(removeButtonTimeoutID, createTimeOutIDClearer())}><slot /></button>

親コンポーネント側では以下のように使う。

<script lang="ts">
    import RemoveButton from "$lib/components/RemoveButton.svelte"

    const outputString = "👋 Goodbye world"

    function createCallback() {
        return () => {
            console.log(outputString)
        }
    }
</script>

<RemoveButton removeButtonClickCallback={createCallback()} />

これによって変数を直接受け渡すことなくRemoveButtonコンポーネントから親コンポーネントの変数outputStringの値を使う関数を実行できる。

データの永続化

Axionsではコスト節約のためにLocalStorageを使ってデータを永続化している。SvelteKitのClient hooksStoreを活用してリアクティブにデータを読み書きすることで、アプリ上のデータとLocalStorageのデータが自動的に同期されるようになっている。以下にhooks.client.tsのコードを掲載する。

import { browser } from "$app/environment"
import * as Types from "$lib/types"
import { writable } from "svelte/store"

// このStoreをimportすることで、ほかのファイルからデータを読み書きできる
export const routinesStore = writable<Types.Routine[]>([])

function init() {
    const rawCache = localStorage.getItem("cache")
    let cache: Types.Cache = {
        routines: []
    }

    if (rawCache != null) {
        cache = JSON.parse(rawCache)
        routinesStore.set(cache.routines)
    }

    // Storeにsubscribeすることでデータが変更されたときに実行する処理を登録できる
    routinesStore.subscribe((value) => {
        cache.routines = value
        // アプリ側でのデータの変更に合わせてLocalStorageにデータを書き込む
        localStorage.setItem("cache", JSON.stringify(cache))
    })
}

// クライアントサイドで実行されていることを保証するためのチェック
if (browser) {
    init()
}

hooks.client.ts内で定義したroutinesStoreをページの遷移前に行う処理を記述する+page.ts内で読み込み、それを元にページを生成することでSPAにありがちなページ読み込み後のレイアウトシフトを防いでいる。

import { get } from "svelte/store"
import { routinesStore } from "./hooks.client"
/** @type {import('./$types').PageLoad} */
export function load() {
    // 一度限りのデータ読み出しのために用意されているget関数を使ってStore内のすべてのデータを読み込んでいる
    const routines = get(routinesStore)

    return {
        routines
    }
}

おわりに

普段はチュートリアルとして書き捨てられることの多いタスク管理アプリをTODOアプリ以外の角度から設計、実装した。内容としては単なる開発ログだが、結果的にSvelteKitのそれぞれの機能の実際のユースケースを説明する「SvelteKitって実際どうなん?」に対する一つの解になったのではないかと思う。

著者プロフィール

著者近影

橘いおね

運用より開発を愛する日曜Web大工Web weekend developer。2021年からWeb開発の勉強を始め、スクラップ&ビルド&スクラップを繰り返して現在に至る。趣味はビデオゲーム(あまり上手ではない)。