時々30分区切りでデータをまとめたい、などの時間をよくある数値とは異なる単位で丸めたいという要望があります。これを実装する方法を紹介します。
実装の方針は”日時オブジェクトを元にタイムスタンプを作り、タイムスタンプの状態で計算を行って再び日時オブジェクトに戻す”というものです。より詳しく手順を示すと以下です。
,
- 元々の値をタイムスタンプに変換
- 30分区切りなどの単位を↑タイムスタンプと同じ単位に変換
- タイムスタンプに変換した元々の値 / タイムスタンプと同じ単位に変換した区切り時間 = 単位数 となる演算をする
- 単位数を(切り上げ or 切り捨て or 四捨五入)して整数に整える
- 整数にした単位数に単位時間をかけて、30分区切りや1時間区切りで整えられた日時のタイムスタンプにする
- 得られたタイムスタンプを元に日時オブジェクトを生成して返す
昔、算数や数学でやった問題にだいぶ近い感じです。単位をそろえて計算する、というそれです。より具体的に JavaScript や PHP のコードに落とし込むと次の様になります。
import {formatISO} from "date-fns";
/**
* 日時を30分単位などで区切る
* @param {number | string | Date} date
* @param {{
* unit: {
* count: number;
* unit: 'min' | 'sec' | 'hour';
* };
* direction?: 'floor' | 'round' | 'ceil';
* }} options
* @return Date
*/
export const datetimeToFix = (date, options) => {
// 渡された日時が日時オブジェクトである Date でなければ、Date に変換する
if (!(date instanceof Date)) {
date = new Date(date);
}
// 1. 元々の値をミリ秒単位に変換
const baseTime = date.getTime();
// 2. 30分区切りなどの単位をミリ秒単位に変換
// この単位で割った結果を整数にした後に、再度単位をかけることで日時として復元させる
const unitMillSec =
options.unit.count *
(options.unit.unit === 'sec'
? 1000 // 渡された値が秒単位ならば × 1000
: options.unit.unit === 'min'
? 60 * 1000 // 渡された値が分単位ならば × 60 × 1000
: options.unit.unit === 'hour'
? 60 * 60 * 1000 // 渡された値が時間単位ならば × 60 × 60 × 1000
: 0); // 想定外の状態だったならば 0
// 3.ミリ秒単位に変換した元々の値 / ミリ秒単位に変換した単位時間 = 単位数 となる演算をする
const unitCount = baseTime / unitMillSec;
// 4.単位数を(切り上げ or 切り捨て or 四捨五入)して整数に整える
// JavaScript の切り上げ、切り捨て、四捨五入のどれでも使える様に options でメソッドを指定させる。もし未定義ならば round で四捨五入する
const unitCountFixed = Math[options.direction ?? 'round'](unitCount);
// 5.整数にした単位数に単位時間をかけて、30分区切りや1時間区切りで整えられた日時のタイムスタンプにする
const retTimestamp = unitCountFixed * unitMillSec;
// 6.得られたタイムスタンプを元に日時オブジェクトを生成して返す
return new Date(retTimestamp);
};
/** テスト */
import 'jest';
import { datetimeToFix } from '@/common/helpers/date/datetimeToFix';
import { formatISO } from 'date-fns';
describe(__filename, () => {
test('datetimeToFix', () => {
const retFloor = datetimeToFix(new Date('2022-11-01T11:41:22+09:00'), {
unit: {
count: 30,
unit: 'min',
},
direction: 'floor',
});
expect(formatISO(retFloor)).toBe('2022-11-01T11:30:00+09:00');
const retRoundF = datetimeToFix(new Date('2022-11-01T11:41:22+09:00'), {
unit: {
count: 30,
unit: 'min',
},
direction: 'round',
});
expect(formatISO(retRoundF)).toBe('2022-11-01T11:30:00+09:00');
const retRoundU = datetimeToFix(new Date('2022-11-01T11:45:01+09:00'), {
unit: {
count: 30,
unit: 'min',
},
direction: 'round',
});
expect(formatISO(retRoundU)).toBe('2022-11-01T12:00:00+09:00');
const retCeil = datetimeToFix(new Date('2022-11-01T11:41:22+09:00'), {
unit: {
count: 30,
unit: 'min',
},
direction: 'ceil',
});
expect(formatISO(retCeil)).toBe('2022-11-01T12:00:00+09:00');
});
});
/** より短く TypeScript で書き下すと次の様になります */
export const datetimeToFix = (
date: Date | string,
options: {
unit: {
count: number;
unit: 'min' | 'sec' | 'hour';
};
direction?: 'floor' | 'round' | 'ceil';
}
): Date => {
if (!(date instanceof Date)) {
date = new Date(date);
}
const unitMillSec =
options.unit.count *
(options.unit.unit === 'sec'
? 1000
: options.unit.unit === 'min'
? 60 * 1000
: options.unit.unit === 'hour'
? 60 * 60 * 1000
: 0);
return new Date(Math[options.direction ?? 'round'](date.getTime() / unitMillSec) * unitMillSec);
};
<?php
// せっかくなので PHP8.2 の readonly クラス や enum を用意。
// 正直もっと大雑把でフェイルセーフの ?? あたりを適当につけておけばいいとも思います。
enum UnitType
{
case HOUR;
case MINUTE;
case SECONDS;
}
enum Direction
{
case ROUND;
case CEIL;
case FLOOR;
}
readonly class DateTimeToFixOption
{
public function __construct(
public int $unitCount,
public UnitType $unitType,
public Direction $direction,
) {
}
}
function dateTimeToFix(DateTimeInterface|string $date, DateTimeToFixOption $options)
{
// 渡された日時が日時オブジェクトである Date でなければ、Date に変換する
// 兼ねて、タイムスタンプにもしておく
// 1. 元々の値をタイムスタンプに変換。JavaScriptと異なって秒単位となる
$baseTime = (!($date instanceof DateTimeInterface))
? strtotime($date)
: $date->getTimestamp();
// 2. 30分区切りなどの単位を秒単位に変換
// この単位で割った結果を整数にした後に、再度単位をかけることで日時として復元させる
if($options->unitType === UnitType::SECONDS) {
$unitSec = $options->unitCount;
} elseif($options->unitType === UnitType::MINUTE) {
$unitSec = $options->unitCount * 60;
} elseif($options->unitType === UnitType::HOUR) {
$unitSec = $options->unitCount * 60 * 60;
} else {
throw new \LogicException('UnitType の分岐が足りてません');
}
// 3.秒単位に変換した元々の値 / 秒単位に変換した単位時間 = 単位数 となる演算をする
$unitCount = $baseTime / $unitSec;
// 4.単位数を(切り上げ or 切り捨て or 四捨五入)して整数に整える
if($options->direction === Direction::ROUND) {
$unitCountFixed = round($unitCount);
} elseif($options->direction === Direction::CEIL) {
$unitCountFixed = ceil($unitCount);
} elseif($options->direction === Direction::FLOOR) {
$unitCountFixed = floor($unitCount);
} else {
throw new \LogicException('Direction の分岐が足りてません');
}
// 5.整数にした単位数に単位時間をかけて、30分区切りや1時間区切りで整えられた日時のタイムスタンプにする
$retTimestamp = $unitCountFixed * $unitSec;
return (new DateTimeImmutable())
->setTimestamp($retTimestamp)
->setTimezone(new DateTimeZone('Asia/Tokyo'));
}
echo dateTimeToFix('2022-11-01T11:41:22+09:00', new DateTimeToFixOption(
unitCount: 30, unitType: UnitType::MINUTE, direction: Direction::ROUND
))->format('c');// 2022-11-01T11:30:00+09:00
自分で作っといてなんですが手作業でやるとめんどくさいです。特に PHP は Carbon ライブラリを使えるならば次の様にでき、そっちの方がずっと楽です。
<?php
require_once __DIR__."/vendor/autoload.php";
// Carbon という日時ライブラリに任意単位の切り上げ、切り捨て、四捨五入が備わっています。
echo \Carbon\Carbon::create('2022-11-01T11:41:22+09:00')
// Pで始まってTで年月日と時分秒を区切る文字列で期間を指定する PHP 組み込みクラスのフォーマット
// @see https://www.php.net/manual/ja/dateinterval.construct.php
->round(new DateInterval('PT30M'))
->toIso8601String();// 2022-11-01T11:30:00+09:00