来るたびに店員が少しずつ親しげになる。
そういうWebサイトで、ページを開くたびに来店回数を増やしたらどうなるでしょうか。
初来店の日でも、F5を連打すれば常連です。店に通うより、キーを連打したほうが早い。店員が覚えているのは、お客さんの顔なのか、キーボードの振動なのか。
個人で作っている After 6 Streetの古書店「望月書店」 では、同じブラウザーでの来店日数に応じて、キャラクターの挨拶を変えています。
そこで数えているのは、ページを開いた回数ではなく、日本時間で何日来たかです。同じ日に再読み込みしても増やしません。
この部分は、フレームワークを使わず、JavaScriptと localStorage で作れます。
「5回来た」と「5日来た」を分ける
判定は、1日目が初回、2〜4日目が再訪、5日目以降が常連です。保存するのは、次の3項目です。
{
"firstDay": "2026-11-02",
"lastDay": "2026-11-03",
"days": 2
}
最後に記録した日より後の日に来たら、日数を1だけ増やします。5日空けて来ても、増えるのは1です。不在だった5日間まで出席にすると、ずいぶん気前のよい店になります。
また、時計が過去に戻ったときには増やしません。「今日と違ったら加算」では、過去と未来を行き来するだけで常連になれてしまいます。
「今日」をUTCに聞かない
日付だけ欲しいとき、次のように書きたくなります。
new Date('2026-11-03T00:00:00+09:00').toISOString().slice(0, 10);
// '2026-11-02'
toISOString() はUTCの文字列を返します。日本では翌日になっていても、UTCではまだ前日です。
店の営業日を日本時間で区切るなら、Intl.DateTimeFormat に timeZone: 'Asia/Tokyo' を指定します。下の例では formatToParts() で年・月・日を取り出し、YYYY-MM-DD に組み立てています。ロケールごとの区切り文字や並び順に頼らないためです。
来店日数を更新するコード
Streetの実装をもとに、記事用の独立した例に整理しました。保存キーはデモ専用です。日付の組み立てには、先ほどの formatToParts() を使っています。
const VISIT_KEY = 'visit-days-demo.v1';
const MAX_DAYS = 100_000;
const formatter = new Intl.DateTimeFormat('en', {
timeZone: 'Asia/Tokyo', calendar: 'gregory', numberingSystem: 'latn',
year: 'numeric', month: '2-digit', day: '2-digit'
});
function japanDay(now) {
const parts = Object.fromEntries(
formatter.formatToParts(now).map(({ type, value }) => [type, value])
);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function validDay(value) {
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
&& Number.isFinite(Date.parse(value))
&& new Date(value).toISOString().slice(0, 10) === value;
}
function validRecord(record) {
return record && Number.isSafeInteger(record.days)
&& record.days >= 1 && record.days <= MAX_DAYS
&& validDay(record.firstDay) && validDay(record.lastDay)
&& record.firstDay <= record.lastDay;
}
function registerVisit(storage, now = new Date()) {
const today = japanDay(now);
let record = null;
try { record = JSON.parse(storage.getItem(VISIT_KEY)); } catch {}
if (!validRecord(record)) {
record = { firstDay: today, lastDay: today, days: 1 };
} else if (today > record.lastDay) {
record = { ...record, lastDay: today, days: Math.min(record.days + 1, MAX_DAYS) };
}
let persisted = false;
try {
storage.setItem(VISIT_KEY, JSON.stringify(record));
persisted = true;
} catch {}
const stage = record.days === 1 ? 'first' : record.days < 5 ? 'return' : 'regular';
return { ...record, persisted, stage };
}
YYYY-MM-DD にそろえているので、today > record.lastDay という文字列比較で日付の前後を判定できます。
保存データは自分のコードが書いたものでも、読み直すときに型と日付を確認します。不正なJSONや壊れた記録は、1日目から作り直します。10万日は、この例で扱う日数の上限です。
ブラウザーから呼ぶときは、次のようにします。
let storage = null;
try { storage = window.localStorage; } catch {}
const visit = registerVisit(storage);
console.log(visit.days, visit.stage, visit.persisted);
window.localStorage の取得自体が例外になる場合もあるため、そこも try の中に入れます。
保存できない日は、入店お断りにしない
コードでは、読み取りと書き込みを別々に扱っています。
読み取れなければ、今回は初回として表示します。読み取りはできて書き込みだけ失敗したなら、読み取れた記録に基づく挨拶を使い、persisted: false を返します。保存に失敗したからといって、読めた記憶まで捨てる必要はありません。
つまり、今回表示する段階と、次回へ保存できたかを分けています。
常連向けの挨拶は演出です。保存の許可がなければ店に入れない、というほど厳しい会員制にはしません。
日本時間の0時をまたいで試す
本体コードを貼ったあと、次のような保存先を渡すと、ブラウザーの実際の記録を変えずに試せます。
const values = new Map();
const storage = {
getItem: key => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value)
};
console.log(registerVisit(storage, new Date('2026-11-02T23:59:59+09:00')).days); // 1
console.log(registerVisit(storage, new Date('2026-11-02T23:59:59+09:00')).days); // 1
console.log(registerVisit(storage, new Date('2026-11-03T00:00:00+09:00')).days); // 2
保存データが空の状態から、次の順で呼び出した結果です。Node.js v24.22.1で確認しました。
| 操作 | 日本時間 | 来店日数 |
|---|---|---|
| 初回 | 11月2日 23:59:59 | 1 |
| 同じ日時で再実行 | 11月2日 23:59:59 | 1 |
| 日付が変わる | 11月3日 00:00:00 | 2 |
| 同日にもう一度 | 11月3日 20:00:00 | 2 |
| 5日空けて再訪 | 11月8日 12:00:00 | 3 |
| 時計を過去へ戻す | 11月1日 12:00:00 | 3 |
不正な保存データ、保存先へのアクセス拒否、書き込みだけ失敗する場合も確認しました。
この値が表すのは、同じ保存領域に残っている来店日数です。データを消したりブラウザーを変えたりすれば引き継がれません。端末の時計にも依存し、複数タブでの読み書きも厳密な排他制御ではありません。挨拶の変化に使い、購入特典や厳密な人数集計の根拠にはしない設計です。
参考資料
常連になるために、連打は必要ない
「来るたびに親しげになる」という小さな演出でも、何を来店と呼ぶかで、店員の記憶はずいぶん変わります。
最後に来た日と、来た日数を分けて記録する。同日の再読み込みは増やさない。保存できなくても、今回は普通に迎える。
これで、常連になる条件を、ページの読み込み回数から来店日数へ合わせられます。
この店の常連認定には、連打力の試験はありません。