引き寄せや癒し系の「今日の1枚」を配信するアプリを個人開発で運用していると、ある時期から少数のユーザーから決まった報告が届くようになりました。「昨日と同じ画像が今日も出る」「連続記録が急にゼロに戻った」。再現条件を聞き取ると、海外渡航中だったり、ゲームの報酬目当てで端末の時計を進めて戻した直後だったりします。リテンションの肝になる機能だけに、こうした戸惑いはそのまま離脱につながります。
日替わりコンテンツは、一見すると「日付が変わったら次を出すだけ」の単純な機能です。しかし new Date() をそのまま信じた瞬間に、タイムゾーン移動・サマータイム・端末時計の手動変更という三つの揺らぎが全部そこに流れ込んできます。これは本番運用で実際に踏んだ罠でもあります。ここでは、その揺らぎを一箇所に閉じ込めて「今日」を決定論的に確定させる設計を、Rork(Expo) で動くコードとしてまとめます。
「今日」を決める場所を一点に絞る
最初にやめるべきは、画面のあちこちで new Date().getDate() を呼ぶことです。比較に使う「今日」が呼び出し箇所ごとにわずかにずれ、深夜0時前後で表示と保存が食い違います。これが二重表示と欠落の温床になります。
代わりに、その瞬間が属する「日」を YYYY-MM-DD の文字列キー(dayKey)に畳み込む関数を一つだけ用意し、アプリ全体がそこだけを通るようにします。
// lib/dayKey.ts
// 基準タイムゾーンは「コンテンツを切り替えたい時計」を一つ決めて固定する。
// 日本向けの日替わりアプリなら Asia/Tokyo を採用すると、
// 海外渡航中のユーザーも「日本の今日」で同じ1枚を見られる。
const CONTENT_TZ = "Asia/Tokyo" ;
export function dayKeyFor ( date : Date , timeZone : string = CONTENT_TZ ) : string {
// Intl で「そのタイムゾーンでの年月日」を取り出す。端末のロケール非依存。
const parts = new Intl. DateTimeFormat ( "en-CA" , {
timeZone,
year: "numeric" ,
month: "2-digit" ,
day: "2-digit" ,
}). formatToParts (date);
const get = ( t : string ) => parts. find (( p ) => p.type === t)?.value ?? "" ;
return `${ get ( "year" ) }-${ get ( "month" ) }-${ get ( "day" ) }` ; // 例: "2026-06-27"
}
export function todayKey ( timeZone : string = CONTENT_TZ ) : string {
return dayKeyFor ( new Date (), timeZone);
}
en-CA ロケールを使うのは、YYYY-MM-DD 形式が標準で得られ、文字列比較がそのまま日付の前後比較になるからです。dayKey 同士は辞書順で比較でき、"2026-06-27" < "2026-06-28" が常に成り立ちます。コンテンツの選択も、ストリークの判定も、保存も、すべてこの文字列だけを見て行います。私はこの一点集約を強く推奨します。
ローカル時刻・固定タイムゾーン・サーバ時刻のどれを基準にするか
「今日」をどの時計で決めるかは、アプリの性格で変わります。バックエンドを持たない個人開発のアプリでは、サーバ時刻に毎回問い合わせる前提は重すぎます。現実的な使い分けを整理します。
基準 挙動 向くアプリ 弱点
端末ローカル時刻 ユーザーの現地の0時で切り替わる 習慣化・日記など「その人の生活時間」が主役 時計の手動変更をそのまま信じる
固定タイムゾーン 常に同じ地域の0時で切り替わる 「日本の今日の運勢」など配信元が主役のコンテンツ 深夜帯の海外ユーザーが直感とずれる
サーバ時刻 権威ある時刻で切り替わる 不正対策が重要な報酬・ランキング オフライン不可・通信コスト・遅延
私が運用している壁紙・癒し系の日替わりアプリでは、配信するコンテンツ側に「その日らしさ」があるため、固定タイムゾーン(Asia/Tokyo)を基準にしています。これで海外にいる読者も「日本の今日の1枚」を共有でき、App Store のサポート問い合わせに来ていた「人によって違う絵が出る」という戸惑いがなくなりました。一方、純粋な習慣トラッカーなら、その人の現地0時で締めるローカル時刻基準のほうが自然です。個人的には、基準は一つに決め、CONTENT_TZ を空文字にしたらローカル、という分岐は作らないことをおすすめします。混在は必ず後で破綻します。
深夜0時のロールオーバーを取りこぼさない
アプリを開いたまま日付をまたぐと、画面はいつまでも「昨日の1枚」を表示し続けます。ここで setTimeout(handleMidnight, msUntilMidnight) を仕込む解法をよく見かけますが、これは取りこぼします。iOS はバックグラウンドのタイマーを止めますし、端末がスリープしている間に0時を越えると発火しません。これが見落としやすい注意点です。
確実なのは「タイマーで待つ」のではなく「復帰時に再計算する」発想です。AppState がアクティブに戻った瞬間と、一定間隔のフォアグラウンド点検で、todayKey() が保持中のキーと変わっていないかだけを確認します。
// hooks/useDailyContent.ts
import { useEffect, useRef, useState } from "react" ;
import { AppState } from "react-native" ;
import { todayKey } from "../lib/dayKey" ;
export function useDailyContent () {
const [ key , setKey ] = useState ( todayKey ());
const keyRef = useRef (key);
keyRef.current = key;
useEffect (() => {
const check = () => {
const now = todayKey ();
if (now !== keyRef.current) setKey (now); // 日付が進んでいたら差し替え
};
const sub = AppState. addEventListener ( "change" , ( s ) => {
if (s === "active" ) check (); // 復帰のたびに点検
});
// 画面を開きっぱなしの人向けに、軽いフォアグラウンド点検も置く
const id = setInterval (check, 60_000 );
return () => {
sub. remove ();
clearInterval (id);
};
}, []);
return key; // この dayKey からコンテンツを引く
}
タイマーは「飾り」で、真実はあくまで todayKey() の再評価にあります。1分間隔の setInterval は深夜帯に画面を開いたまま放置している人のためのもので、これが多少ずれても復帰時の点検が必ず拾います。本番ではこの「復帰時再計算」が効きました。
端末時計の巻き戻しをストリークに波及させない
ここからが本題です。Date を信じてストリーク(連続日数)を数えると、時計を1日進めて報酬を得て、戻したユーザーに対して連続記録を二重に与えてしまいます。逆に、海外渡航から戻って端末時刻が巻き戻ったとき、正規ユーザーの記録を理不尽に消すこともあります。これも対処が必要な落とし穴です。
鍵は二つの時計を併用することです。日付の判定には壁時計(Date)を使い、巻き戻りの検出には単調増加クロックの考え方を使います。最後に観測した壁時計を保存しておくことで、「壁時計が過去へ飛んだ」という矛盾を見つけられます。
// lib/streak.ts
import AsyncStorage from "@react-native-async-storage/async-storage" ;
import { todayKey } from "./dayKey" ;
type StreakState = {
lastDayKey : string ; // 最後に達成した日
count : number ; // 連続日数
lastSeenWall : number ; // 最後に観測した壁時計(ms)
};
const KEY = "streak.v1" ;
function diffInDays ( a : string , b : string ) : number {
// dayKey 同士の日数差。UTC正午で固定して DST の影響を消す。
const da = new Date ( `${ a }T12:00:00Z` ). getTime ();
const db = new Date ( `${ b }T12:00:00Z` ). getTime ();
return Math. round ((db - da) / 86_400_000 );
}
export async function recordToday () : Promise < StreakState > {
const raw = await AsyncStorage. getItem ( KEY );
const now = Date. now ();
const today = todayKey ();
if ( ! raw) {
const fresh = { lastDayKey: today, count: 1 , lastSeenWall: now };
await AsyncStorage. setItem ( KEY , JSON . stringify (fresh));
return fresh;
}
const s : StreakState = JSON . parse (raw);
// 壁時計が過去に巻き戻っている → 時刻操作の疑い。記録は据え置き、加算しない。
if (now < s.lastSeenWall - 60_000 ) {
s.lastSeenWall = now;
await AsyncStorage. setItem ( KEY , JSON . stringify (s));
return s;
}
const gap = diffInDays (s.lastDayKey, today);
if (gap <= 0 ) {
// 同じ日に複数回開いた。二重加算しない。
s.lastSeenWall = now;
} else if (gap === 1 ) {
s.count += 1 ; // 昨日からの正規の継続
s.lastDayKey = today;
s.lastSeenWall = now;
} else {
// 2日以上空いた。1日だけグレースを認めるなら gap === 2 を継続扱いにする。
s.count = 1 ;
s.lastDayKey = today;
s.lastSeenWall = now;
}
await AsyncStorage. setItem ( KEY , JSON . stringify (s));
return s;
}
ポイントは三つあります。第一に、加算の判定は dayKey の差分だけで行い、Date の細かな差は見ません。第二に、同じ dayKey で何度開かれても gap <= 0 で弾くので、二重加算は構造的に起きません。第三に、壁時計が大きく過去へ飛んだら、その回は記録を据え置いて加算を止めます。これで「進めて戻す」ことによる水増しを防ぎつつ、正規ユーザーの記録を消さずに済みます。
タイムゾーン移動を「1日飛ぶ・2回来る」にしない
dayKey を固定タイムゾーンで生成している限り、ユーザーが日本からハワイへ飛んでも基準は Asia/Tokyo のままなので、日付境界は揺らぎません。これがローカル時刻基準だと、東へ飛べば一日が短くなって連続が切れやすく、西へ飛べば一日が伸びて同じ日が二度訪れます。固定タイムゾーンは、配信元が主役のコンテンツでは旅行者の事故を一掃する最も簡単な手当てです。
ローカル時刻基準を選ばざるを得ないアプリ(その人の生活が主役の習慣トラッカーなど)では、diffInDays を UTC 正午で固定している点が効きます。T12:00:00Z を挟むことで、DST の前後でも端の1時間で日数が±1ぶれる事故を防げます。日付の引き算を素朴に getTime() の差で行うと、ここで必ず躓きます。
手で時計を動かして確かめる
この種のロジックは、実機で時刻を操作して初めて穴が見えます。リリース前に最低限ここを通します。
操作 期待する挙動
アプリを開いたまま0時をまたぐ 復帰または1分以内に「今日の1枚」が切り替わる
端末時刻を翌日へ進める コンテンツは進むが、戻した直後は加算されない
端末時刻を前日へ戻す 連続記録は減らず、据え置きになる
タイムゾーンを海外へ変更 固定TZ基準なら日付境界は不変
同じ日に5回開く 連続記録は1しか増えない
diffInDays と recordToday は副作用の少ない純粋関数に寄せてあるので、ユニットテストで境界(gap が 0/1/2、壁時計巻き戻し)を固めておくと安心です。日付絡みの不具合は再現が難しく、レビューでも見落としがちなので、テストで線を引いておく価値が特に高い領域です。
導入の順序
既存アプリに後付けする場合は、次の順で進めると安全です。
dayKey 関数を一つ立て、画面から散らばった new Date() 比較を全部そこへ寄せる。表示のずれが最初に消えます。
useDailyContent で復帰時再計算を入れ、開きっぱなしの端末でも0時で切り替わるようにする。
最後に recordToday のストリーク整合(巻き戻し検知とグレース)を載せ、実機で時計を動かして検証する。
日替わりコンテンツの体験は、地味なこの一点の堅さで決まります。表示のずれが消えるだけでも、サポートに届く戸惑いの声がはっきり減るはずです。お読みいただきありがとうございました。