# `PhoenixKitPhotos.Timeline`
[🔗](https://github.com/BeamLabEU/phoenix_kit_photos/blob/v0.3.0/lib/phoenix_kit_photos/timeline.ex#L1)

The server side of the timeline: a monthly index and per-day windows
over one storage library.

## Scope

v1 is `{:library, uuid}`. A library is the partition PhoenixKit stores
files in (`PhoenixKit.Modules.Storage.Libraries`). The caller also passes
the current `PhoenixKit.Users.Auth.Scope`, and every file in a window is
checked with `Libraries.can?/3` before a URL is minted.

The fast index is `phoenix_kit_files_library_capture_date_index` on
`(library_uuid, taken_on, taken_at)`. Months follow `taken_on`, the local
calendar date, not the UTC instant in `taken_at`.

## The two shapes

The index is a cheap aggregation — a few hundred month buckets for a
library of any size:

    %{
      total: 184_203,
      state: :ready,
      sections: [
        %{
          id: "2018-07",
          label: "July 2018",
          count: 412,
          sum_aspect: 548.2,
          days: 19,
          days_list: [%{id: "2018-07-14", count: 20}]
        }
      ]
    }

A window is a day or a 500-item slice of a larger day, ordered by
`taken_at`. `label` is already translated.

`state` is `:ready` when the index has photos. An empty library is
`:empty`, `:processing` (an upload is not active yet), or `:backfill`
(capture dates are still being filled in).

# `day_count`

```elixir
@type day_count() :: %{id: window_id(), count: non_neg_integer()}
```

# `error`

```elixir
@type error() :: :capture_date_unavailable | :not_found | :unauthorized
```

# `index`

```elixir
@type index() :: %{total: non_neg_integer(), state: state(), sections: [section()]}
```

# `item`

```elixir
@type item() :: %{
  id: String.t(),
  taken_at: DateTime.t() | nil,
  w: pos_integer(),
  h: pos_integer(),
  ar: float(),
  thumb: String.t() | nil,
  fit: String.t() | nil,
  preview: String.t() | nil,
  large: String.t() | nil,
  kind: :image | :video,
  code: String.t() | nil,
  name: String.t() | nil,
  size: non_neg_integer() | nil,
  duration: non_neg_integer() | nil
}
```

# `scope`

```elixir
@type scope() :: {:library, String.t()}
```

# `section`

```elixir
@type section() :: %{
  id: section_id(),
  label: String.t(),
  count: non_neg_integer(),
  sum_aspect: float(),
  days: non_neg_integer(),
  days_list: [day_count()]
}
```

# `section_id`

```elixir
@type section_id() :: String.t()
```

A month bucket: `"2018-07"`.

# `slide`

```elixir
@type slide() :: %{
  :id =&gt; String.t(),
  :kind =&gt; :image | :video,
  :w =&gt; pos_integer(),
  :h =&gt; pos_integer(),
  optional(:code) =&gt; String.t() | nil,
  optional(:src) =&gt; String.t() | nil,
  optional(:poster) =&gt; String.t() | nil,
  optional(:sources) =&gt; [
    %{url: String.t(), type: String.t(), role: :rendition | :original}
  ]
}
```

What the swipe viewer needs for one file: a picture (`src`), or a video
(`poster` and `sources`). `w` and `h` are the size of the picture the viewer
shows, never the stored size of a video (a phone clip is stored as landscape
and played as portrait).

# `state`

```elixir
@type state() :: :ready | :empty | :processing | :backfill
```

# `window`

```elixir
@type window() :: %{section: window_id(), items: [item()]}
```

# `window_id`

```elixir
@type window_id() :: String.t()
```

A day window: `"2018-07-14"`.

# `after_cursor`

```elixir
@spec after_cursor(term()) :: {:ok, Ecto.Query.dynamic_expr()} | :error
```

The condition of the files after a `cursor/1`, or `:error` when it is not one.

# `aspects`

```elixir
@spec aspects(scope(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, %{required(section_id()) =&gt; binary()}} | {:error, error()}
```

The aspect ratio of every file of a library, for the styles that show whole photos: a map
of section id to one byte per file, in the order the timeline shows them.

With these the browser knows the exact height of every month without fetching a single
window, so the scrollbar and jump to month stay honest in those styles too. A byte is the
logarithm of the ratio from 1:8 to 8:1 in 256 steps of 1.6%, which is finer than anyone can
see in a crop and costs 260 KB for 200,000 files, once. `assets/js/layouts.js` reads it.

# `base_filters`

```elixir
@spec base_filters() :: keyword()
```

The predicates every timeline query must carry, on `phoenix_kit_files`.

`status == "active"` keeps processing and failed rows out — they have no
variants and would render as permanently grey tiles. The dimension
predicate keeps `ar` from blowing up the layout.

`edit_state` is absent on purpose. A pending or failed edit occupies the
same uuid and belongs in the grid.

# `cursor`

```elixir
@spec cursor(PhoenixKit.Modules.Storage.File.t()) :: String.t()
```

A place in the timeline's order, for paging. It holds the place itself rather than naming a
file, so a page still follows on when the file it ended on has since been trashed or moved.

# `default_library_uuid`

```elixir
@spec default_library_uuid(PhoenixKit.Users.Auth.Scope.t() | nil) :: String.t() | nil
```

The library the timeline opens when the caller does not name one.

A user's default personal library when they have one, otherwise Media when
they may see it (`media_visible?/1`), otherwise none.

# `files_query`

```elixir
@spec files_query(scope(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, Ecto.Query.t()} | {:error, error()}
```

The query of what a timeline shows, for code that narrows it further (`PhoenixKitPhotos.API`).
The library is checked for `auth`; each file of the answer still has to pass `Libraries.can?/3`.

# `group_days`

```elixir
@spec group_days([
  %{taken_on: Date.t(), count: non_neg_integer(), sum_aspect: number()}
]) :: [section()]
```

Folds per-day counts into month sections.

`taken_on` decides the month. The label is translated before it reaches
the hook.

# `ids_between`

```elixir
@spec ids_between(scope(), PhoenixKit.Users.Auth.Scope.t() | nil, keyword()) ::
  {:ok, [String.t()], boolean()} | {:error, error()}
```

The ids of positions `from` to `to` of the timeline, both included, at most `limit`. With
`:kind` (`"image"` or `"video"`) the positions are those of that kind only, and without
`from` and `to` that is the whole library.

This is what a selection asks when a range runs through months the browser has not fetched.

# `index`

```elixir
@spec index(scope(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, index()} | {:error, error()}
```

Monthly index for one library.

Live aggregation, grouped by `taken_on`. A photo taken late in the evening
whose UTC instant falls on the next day stays in the month of `taken_on`.

# `libraries`

```elixir
@spec libraries(PhoenixKit.Users.Auth.Scope.t() | nil) :: [
  PhoenixKit.Modules.Storage.Library.t()
]
```

Libraries the scope may open in the timeline: the system library Media for
someone who can manage it (`media_visible?/1`), then the user libraries they
own or belong to.

Media does not require user libraries to be enabled. A personal library
does.

# `media_visible?`

```elixir
@spec media_visible?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()
```

Whether the site's Media library is one of this scope's libraries.

It belongs to the site, not to a person: a system role or access to the media
module (the rule for adding to it, `Uploads.can_upload?/2`). To anyone else it
would be an empty library they cannot add to, since a user reads only their own
uploads there.

# `month_label`

```elixir
@spec month_label(section_id()) :: String.t()
```

Already-translated month heading for a `"2018-07"` section id.

# `position`

```elixir
@spec position(scope(), String.t(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, non_neg_integer()} | {:error, error()}
```

Where a photo is in the timeline: how many photos of the library come before it, in
the order the timeline shows them (by day, then `taken_at`, then uuid). The browser
opens the swipe viewer at that position, so an address that names a photo can open on it.

`{:error, :not_found}` for a file that is not in the library or that `auth` may not read.

# `slide_limit`

```elixir
@spec slide_limit() :: pos_integer()
```

Most files `slides/6` returns at once.

# `slides`

```elixir
@spec slides(
  scope(),
  window_id(),
  integer(),
  integer(),
  integer(),
  PhoenixKit.Users.Auth.Scope.t() | nil
) :: {:ok, [slide()]} | {:error, error()}
```

A slice of a window for the swipe viewer: `count` files (at most
`slide_limit/0`) from position `from` of the day or day-slice `day`, in the
order `window/3` gives them, each with full-size URLs for a screen `want`
pixels wide.

`from + count` may run past the end of a 500-item slice into the next one: the
position is the same in the day, so the caller's index stays right.

# `touches?`

```elixir
@spec touches?(scope(), [String.t()]) :: boolean()
```

Whether a storage event about `file_uuids` can change what library `scope` shows.

Storage announces every file event to everyone, whichever library the file is in. This
answers from where the files are kept, trashed ones included (a trashed file leaves the
timeline but is still a row of its library). Files that no longer exist cannot be placed, so
they count: someone's gone for good, and the timeline asks.

# `viewer_file`

Read-authorized data for PhoenixKit's existing canvas viewer.

# `window`

```elixir
@spec window(scope(), window_id(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, window()} | {:error, error()}
```

One day of items for a library, at most `window_limit/0`, ordered by
`taken_at`.

Thumbnail and preview URLs come from `Storage.variant_for/2` (a crop and
an aspect-preserving size). The original is never used as a tile. Each
URL is minted with `Storage.authorized_url/4`, which refuses a file the
scope may not read.

# `window_limit`

```elixir
@spec window_limit() :: pos_integer()
```

Maximum items returned in a single window; larger days are subdivided.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
