家計簿アプリを作る #67:メモ欄の入力補完
家計簿アプリ作成シリーズの第67回です。収支登録・編集フォームの「メモ」欄で、過去に入力したメモを候補として表示し、入力の手間を減らします。
<datalist>でブラウザネイティブの入力補完
<datalist> はHTML標準の機能で、<input> に list 属性で紐付けるだけで候補表示ができます。flowbite-svelteの Input も内部的には <input> をレンダリングしているだけなので、そのまま list 属性を渡せば動作しました。
<Input type="text" id="memo" name="memo" list="suggestions" />
<datalist id="suggestions">
{#each data.pastMemos as memo}
<option value={memo}></option>
{/each}
</datalist>
重複除去とソート順の組み合わせでハマったポイントの前に:型エラーの再発
実装中、transactions/new の登録処理で以前も遭遇した型エラーが再発しました。
型 'string' を型 'SQL<unknown> | "income" | "expense" | Placeholder<string, any>' に割り当てることはできません。
原因は、type を as 'income' | 'expense' でキャストしていたにもかかわらず、insert 時に type: String(type) と String() で包んでしまい、せっかくのリテラル型が string に戻ってしまっていたことでした。
// ❌ String()でリテラル型が失われる
type: String(type),
// ✅ 既にキャスト済みなのでそのまま使う
type: type,
キャストした変数をさらに別の関数で包むと、型が意図せず広がってしまうことがある、という点を再確認しました。
Setで「重複除去」と「最新優先」を同時に行う
DBから取得した過去のメモを、重複を除いて新しい順に一定件数だけ候補にしたいという要件がありました。このアプリの既存方針(1回のクエリで取得してJS側で加工する)に合わせて、Set を使ったシンプルな実装にしました。
const pastMemos = await db.select({ memo: transactions.memo, date: transactions.date })
.from(transactions)
.where(eq(transactions.userId, locals.user!.id))
.orderBy(desc(transactions.date));
const uniqueMemos = Array.from(new Set(pastMemos.map(m => m.memo)))
.filter(memo => memo !== '')
.slice(0, 20);
ポイントは、Set が「重複を除去しつつ、最初に出現した順序を保持する」という性質を持っていることです。
pastMemosを日付の降順(新しい順)であらかじめorderByしておく.map(m => m.memo)でメモの文字列だけの配列にするnew Set(...)に渡すと、同じメモ文字列は「最初に出てきたもの(=一番新しい日付のもの)」だけが残るArray.from(...)で通常の配列に戻し、.filter()で空文字を除外、.slice(0, 20)で件数を絞る
DISTINCT と ORDER BY をSQL側で組み合わせる方法も検討しましたが、「重複除去しつつ各メモの最新日付でソートする」というクエリはSQLite上でも癖があり複雑になりがちなため、素直にJS側で処理する方針にしました。
まとめ
| ポイント | 内容 |
|---|---|
| 入力補完の実装 | <datalist> + list属性でブラウザネイティブに実現できる |
| キャスト済み変数の再ラップ | String()などで包み直すとリテラル型が失われることがある |
| 重複除去と優先順位 | Setは最初の出現順を保持するため、事前ソート+Setで「重複除去しつつ最新を優先」を実現できる |
| SQLとJSの役割分担 | 複雑になりがちなSQLクエリより、既存方針に沿ってJS側で加工する方がシンプル |