ニュース系ウェブサイトの記事リストやソーシャルメディアのフィードなどでは、各記事の公開日時を相対表記で表現することがあります。「5分前」「3時間前」「きのう」というように、記事の公開からどれだけ時間が経過しているかを表現する形式です。具体的には、JavaScriptで記事のタイムスタンプをユーザーの閲覧時の日時と比較し、相対表記に変換することになります。一方、「2018年10月1日」のような形式は絶対表記と呼ばれます。各記事の個別ページでは絶対表記が採用されることが多いようです。

もっとも単純な相対日時の実装は、60分前までは「m分前」、24時間前までは「h時間前」、そしてそれより前は「d日前」というように、時間の単位どおり機械的に処理したものです。しかし私たちの日時の捉え方はカレンダーや時計のとおりではなく、もっと感覚的なものです。日時の相対表記を導入する意義は、正確に何時間何分前の記事なのかを示すのではなく、「いつ頃の記事か」というユーザーの感覚的な理解を促すことができる点にあると言えます。そのためには日時をただ機械的に変換するのではなく、人間の心理を考慮に入れて調整する必要があります。

私たちがUIデザインとフロントエンド開発を担当したニュースメディア、FNN.jpプライムオンラインでも、記事リストで相対日時を採用しています。開発時にチームで議論を重ねながらより良い変換ロジックを模索し、その結果以下のルールにたどり着きました。

  1. 記事が10分前未満の場合は1分単位で「m分前」形式
  2. 記事が60分前未満の場合は5分単位の切り捨てで「m分前」形式
  3. 記事が7時間前未満の場合は1時間単位の切り捨てで「h時間前」形式。ただし現在時刻が午前6時台かつ記事の日付が1日前の場合を除く
  4. 記事の日付が現在と同じ場合は「きょう」
  5. 記事の日付が現在の1日前の場合は「きのう」
  6. 記事の日付が現在の3日前までの場合は「d日前」形式
  7. 記事の年が現在と同じ場合は「MD日」形式
  8. 記事の年が現在と異なる場合は「YMD日」形式

記事の日時をユーザーのローカルの日時と比較して上から順に評価し、合致するルールが適用されます。以下、それぞれのルールについて、そこへ至った考察とともに解説します。

m分前

FNN.jpでは相対日時の最小単位を「秒」ではなく「分」としました。つまり記事の公開の1秒後にアクセスしたとしても「1分前」と表示されます。Twitterやチャットツールのような即時性の強く求められるものでは秒単位の表記が必要とされることもありますが、ほとんどのメディアサイトでは分を最小単位としてよいでしょう。

原則として表記する時間の単位は新しい記事ほど細かく、古いほど粗くなるようにしています。そのため現在から見て10分前までは1分単位で表示しますが、10分前以前は5分単位での切り捨てとしました。51分前も54分前も同じく「50分前」です。

h時間前、きょう、きのう

60分以上前の記事は「h時間前」表記になりますが、ここで「1時間前」から「2時間前」に切り替わるのはどのタイミングか、という論点があります。1時間未満の分単位はすべて切り捨てる設計もありますし、たとえば1時間30分前までは「1時間前」で1時間31分以前は「2時間前」とする、といった設計も考えられます。

これはなかなか判断が難しいところですが、FNN.jpでは分単位はすべて切り捨てています。1時間59分前までは「1時間前」で、2時間たったところで「2時間前」。なぜか?

たとえば1時間50分前に公開された記事に対して「公開されたのは2時間前」と言っても大きな間違いではありませんが、「公開されてから2時間たっている」と言うのは抵抗を感じます。つまり「何時間前に公開された記事か」という表記は、同時に「記事が公開されてから何時間たっているか」ということも表現します。ニュースというメディアの特性を考慮すると、できる限り誤解を招きにくい表現を選択すべきと考え、このような設計となりました。

また、24時間以内ならすべて「h時間前」と表現してよいのか、という疑問もあります。「1時間前」「2時間前」と言われたとき、多くの人はだいたいの時間感覚を即座に捉えられると思います。しかし「5時間前」はどうでしょう。「9時間前」「13時間前」「17時間前」では? つまり「h時間前」はどこまで直観的に捉えられるか、という問題です。

FNN.jpでは「h時間前」表記は「6時間前」まで――つまり6時間59分前までとしました。これは、6時間前はアナログ時計の短針の180°にあたるので、そこまでの時間なら直観的に把握できるのではないか、という仮説に基づいています。そして7時間以上前の場合、日付けが現在と同じなら「きょう」、1日前なら「きのう」と表記します。

さらにここで、たいへん細かいのですが、ちょっと気になる点がありました。このルールによると、午前6:00にサイトを見たとき、前日の午後11:30の記事は「6時間前」になります。しかし午前6:00と言えば「朝」です。朝6:00から見て前日夜11:30の記事は「6時間前」でも間違いではありませんが、どちらかというと「きのう」の方がしっくりくるのではないか、と考えました。そこで「記事が7時間前未満の場合は1時間単位の切り捨てで「h時間前」形式」というルールに、「ただし現在時刻が午前6時台かつ記事の日付が1日前の場合を除く」という条件を加えました。午前6時台になったら、前日の記事はすべて「きのう」になります。

d日前、そしてそれ以前

「きょう」「きのう」の次は「d日前」です。ここでも、「d日前」はいつまでか、いつから「MD日」形式に切り替えるのか――つまり「d日前」はいつまで直観的に捉えられるか、という問題があります。

これはメディアの特性によるところが大きいかもしれません。ニュースメディアでは記事がリリースされない日はまずなく、記事が「過去のもの」になるスピードも速いと言えます。そこで「d日前」表記は「3日前」までとし、それ以前は「MD日」としました。さらに、去年以前の記事は「年」表記を追加し、「YMD日」となります。

絶対表記のフォーマット

記事のフィードで相対表記を採用しているサイトの多くが、記事の個別ページでは絶対表記を採用しています。FNN.jpも同様で、「2018年10月1日 月曜 午後0:30」という表記を採用しています。この絶対表記のフォーマットについても合わせて解説します。

まず年月日について。表記には「2018/10/1」「2018-10-01」「Oct 1, 2018」など多くのパターンが考えられます。ここでも判断の決め手となったのは「まぎれ」のないことが最優先であるニュースというメディアの特性です。読み間違いようのない「2018年10月1日」というフォーマットとしました。

曜日はそもそも表記しないメディアも少なくありません。たしかに映画・音楽などのレビューや、技術系ブログといったものには、記事の公開された曜日の情報はあまり必要なさそうです。しかしニュースメディアでは内容によって曜日が重要な意味を持つこともあるし、またユーザーが複数の記事を時系列で把握したいときにその助けにもなりえると考え、曜日を明記することとしました。

曜日の表記は、「2018年10月1日(月)」のように丸括弧の中に入れるパターンを多く見かけますが、私たちが採用したのは「2018年10月1日 月曜」という、「年月日」のあとに半角スペースをはさんで曜日を入れる形式です。

なぜ「月」でも「月曜日」でもなく「月曜」としたか。「月」だけではスクリーンリーダーでの読み上げに不安があり、曜日であることが伝わらないかもしれないという懸念があります。「月曜日」形式を避けたのは視覚上の問題で、やや冗長に感じることと、曜日の「日」が日曜の「日」とかぶって誤読の可能性があることが理由です。以上の結果として「月曜」形式の採用となりました。

時刻は、夜10時を「22:00」とする24時制よりも、「午後10:00」のように午前・午後を併記する12時制の方がまぎれがないと考え、採用しました。そのため真夜中は「午前0:00」、正午は「午後0:00」となります。

実装

さて、以上のルールをJavaScriptで実装するとこのようになります――

/**
 * @param {Date} value
 * @param {Date} other
 * @returns {boolean}
 */
const areDatesSameYear = (value, other) => {
  return value.getFullYear() === other.getFullYear()
}

/**
 * @param {Date} value
 * @param {Date} other
 * @returns {boolean}
 */
const areDatesSameMonth = (value, other) => {
  return value.getMonth() === other.getMonth()
}

/**
 * @param {Date} value
 * @param {Date} other
 * @returns {boolean}
 */
const areDatesSameDay = (value, other) => {
  return value.getDate() === other.getDate()
}

/**
 * @param {Date} value
 * @param {Date} other
 * @returns {boolean}
 */
const areDatesSameDate = (value, other) => {
  return [areDatesSameYear, areDatesSameMonth, areDatesSameDay].every(
    (areDatesSame) => {
      return areDatesSame(value, other)
    },
  )
}

/**
 * 相対日時表記形式に則った文字列に変換
 *
 * @param {number} from - 基準時刻のUNIX時間(現在)
 * @param {number} to - 比較対象時刻のUNIX時間(過去)
 * @example formatToRelativeTime(Date.now(), Date.parse('2000-01-01T00:00:00+09:00'))
 * @returns {(string|undefined)} 相対日時表示形式に則った文字列を返す
 */
const formatToRelativeTime = (from, to) => {
  const diffMs = from - to
  const diffSeconds = diffMs / 1000
  const diffMinutes = diffSeconds / 60
  const diffHours = diffMinutes / 60
  const diffDays = diffHours / 24

  const isFuture = diffMs < 0
  if (isFuture) {
    return
  }

  const isDiffLessThan10Min = diffMinutes < 10
  if (isDiffLessThan10Min) {
    const truncatedMinutes = Math.trunc(diffMinutes)
    const displayMinutes = Math.max(truncatedMinutes, 1)
    return `${displayMinutes}分前`
  }

  const isDiffLessThan60Min = diffMinutes < 60
  if (isDiffLessThan60Min) {
    const unit = 5
    const truncatedMinutes = diffMinutes - (diffMinutes % unit)
    return `${truncatedMinutes}分前`
  }

  const fromDate = new Date(from)
  const toDate = new Date(to)

  const isDiffLessThan7Hours = diffHours < 7
  if (isDiffLessThan7Hours) {
    const isAfter6Hours = fromDate.getHours() >= 6
    const isToday = areDatesSameDay(fromDate, toDate)

    if (isAfter6Hours && !isToday) {
      return 'きのう'
    }

    const truncatedHours = Math.trunc(diffHours)
    return `${truncatedHours}時間前`
  }

  const isToday = areDatesSameDate(fromDate, toDate)
  if (isToday) {
    return 'きょう'
  }

  const msPerDay = 1000 * 60 * 60 * 24
  const yesterdayOfFrom = from - msPerDay
  const yesterdayOfFromDate = new Date(yesterdayOfFrom)
  const isYesterday = areDatesSameDate(yesterdayOfFromDate, toDate)
  if (isYesterday) {
    return 'きのう'
  }

  const isDiffLessThan4Days = diffDays < 4
  if (isDiffLessThan4Days) {
    const truncatedDays = Math.trunc(diffDays)
    return `${truncatedDays}日前`
  }

  const displayYear = `${toDate.getFullYear()}年`
  const displayMonth = `${toDate.getMonth() + 1}月`
  const displayDay = `${toDate.getDate()}日`

  const isThisYear = areDatesSameYear(fromDate, toDate)
  if (isThisYear) {
    return `${displayMonth}${displayDay}`
  }

  return `${displayYear}${displayMonth}${displayDay}`
}

module.exports = {
  formatToRelativeTime,
}

テストも含むソースコードはyuheiy / formatToRelativeTimeをご覧ください。

おわりに

ここで紹介したロジックはニュースというメディアの特性に依存している上、多くの部分が私たちの仮説に基づいたものです。時間の捉え方は時代や地域によっても異なるでしょう。そのためユーザーやクライアントのフィードバックに耳を傾けつつ、つねにアップデートし続ける姿勢が必要なことは言うまでもありません。

日時表記はウェブサイトやアプリケーションのUIではありふれたものですが、そのディテールはデザインのプロセスでは見過ごされがちなように思えます。このようにあらためて考える機会を得られたことはプロジェクトの大きな収穫でした。