1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

F5を連打しただけで常連にしない――素のJavaScriptで「来店日数」を数える

1
Posted at

来るたびに店員が少しずつ親しげになる。

そういう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

不正な保存データ、保存先へのアクセス拒否、書き込みだけ失敗する場合も確認しました。

この値が表すのは、同じ保存領域に残っている来店日数です。データを消したりブラウザーを変えたりすれば引き継がれません。端末の時計にも依存し、複数タブでの読み書きも厳密な排他制御ではありません。挨拶の変化に使い、購入特典や厳密な人数集計の根拠にはしない設計です。

参考資料

常連になるために、連打は必要ない

「来るたびに親しげになる」という小さな演出でも、何を来店と呼ぶかで、店員の記憶はずいぶん変わります。

最後に来た日と、来た日数を分けて記録する。同日の再読み込みは増やさない。保存できなくても、今回は普通に迎える。

これで、常連になる条件を、ページの読み込み回数から来店日数へ合わせられます。

この店の常連認定には、連打力の試験はありません。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?