If instead of rounding time intervals you would like to round integer numbers, let's say to "the nearest dozen", you would do the follwing operation:
number::integer / 12 * 12
Although dividing and multiplying by 12 seems to "just do nothing", it actually gets rid of the "fraction of a dozen" in your number. The trick lies in the fact that your division is an integer division, and the result is an integer number with no fractional part.
Try, for instance:
SELECT
1 / 12 * 12 AS n1,
2 / 12 * 12 AS n2,
11 / 12 * 12 AS n11,
12 / 12 * 12 AS n12,
13 / 12 * 12 AS n13,
23 / 12 * 12 AS n23,
24 / 12 * 12 AS n24,
25 / 12 * 12 AS n25,
35 / 12 * 12 AS n35 ;
This is a standard way to "rounding down" numbers to bin them, given a certain bin size (12, in this case).
You can do the same with times, as sooun as you convert timestamps to integers (or bigints). You do that by representing timestamps as "number of seconds since a certain date and time". The "epoch" of a timestamp represents the number of seconds elapsed since a certain time and date (1st Jan 1970, 00:00:00). PostgreSQL (and I think, SQL in general) uses "EXTRACT (EPOCH FROM ts)" to get this value.
If you want to round these epochs to any interval (in your case 10 minutes = 60 * 10 seconds), you can make an integer division of this epoch by 600 (so, you get rid of the decimals) and multipy back by this 600. This way, you get rid of the "fractional part" of your 10 minute intervals.
The following code will give you the second version of your possible solutions (with a couple of extra rows for Wonder Woman):
CREATE TABLE hero_stamps (hero text, ts timestamp without time zone) ;
INSERT INTO
hero_stamps (hero, ts)
VALUES
('Batman', '2016-12-08 12:00:00'),
('Batman', '2016-12-08 12:07:00'),
('Batman', '2016-12-08 13:00:00'),
('Batman', '2016-12-08 14:00:00'),
('Wonder Woman', '2016-12-08 10:15:00'),
('Wonder Woman', '2016-12-08 10:18:00'),
('Wonder Woman', '2016-12-08 10:25:00'),
('Wonder Woman', '2016-12-08 11:30:00') ;
SELECT
hero,
start_time,
(CASE WHEN start_time = end_time THEN NULL ELSE end_time END) AS end_time
FROM
(
SELECT
hero,
extract(epoch from ts)::bigint /* timestamp converted to epoch */
/ (60 * 10)::integer /* integer div */
* (60 * 10)::integer /* multiply back to have start time */
AS epoch_start_time_of_interval,
min(ts) AS start_time,
max(ts) AS end_time
FROM
hero_stamps
GROUP BY
hero, epoch_start_time_of_interval
) AS s0
ORDER BY
hero, start_time ;
The GROUP BY makes the "binning", the min(ts) and max(ts) gives you the maximum and minimum values, and the extra function in the outermost SELECT gives the nulls the way you want (and gets rid of the "epoch_start_time_of_interval" that you're not interested in).
This approach will work even if you have timestamps with the same "hour" and "minute" but different calendar days, because it does not ignore this information (it gets embedded in the "epoch").