I posted a comprehensive solution for grouping by shift some time ago (fully dynamic table-driven shift definitions):
It relies on a PostgreSQL table-generating function for efficiency and convenience, but that can be replaced with a recursive common table expression.
The key is, given a timestamp, produce the timestamp for the beginning of the shift that falls in. As a computed column in a subquery. Then the outer query can group on that start-of-shift timestamp.
The comprehensive solution provides a joinable, dynamically-generated set of shift periods that can also be used to truncate timespans.