# Session record

A day at the range: its date, place name, notes, the device that made it, and the window of ranges guided calibration can choose from.

## Generated table

**Record:** session · **Covers:** `id`, `created_at`, `updated_at`, `deleted`, `owner_id`, `date`, `location_name`, `notes`, `device_id`, `target_window`, `measured_mv`

| Field | Type | Required | Limits in the schema | Solver reads | In a shared log |
|---|---|---|---|---|---|
| `created_at` | string | yes | - | no | as stored |
| `date` | string | yes | - | no | replaced by the UTC day it was made on a public share |
| `deleted` | boolean | yes | - | no | as stored |
| `device_id` | string | yes | - | no | set by the server for each share |
| `id` | string | yes | - | no | set by the server for each share |
| `location_name` | string | yes | - | no | emptied when location is hidden |
| `measured_mv.asked` | boolean | no | - | no | removed for every reader |
| `measured_mv.mode` | `'all'` or `'latest'` | yes, within | - | no | removed for every reader |
| `measured_mv.on` | boolean | yes, within | - | no | removed for every reader |
| `measured_mv.profile_id` | string | yes, within | - | no | removed for every reader |
| `notes` | string | yes | - | no | emptied when notes are hidden |
| `owner_id` | string or null | yes | - | no | set by the server for each share |
| `target_window.azimuth_true_deg` | number | no | - | no | rounded to 10° when location is hidden |
| `target_window.inclination_deg` | number | no | - | no | as stored |
| `target_window.max_yd` | number | yes, within | - | no | as stored |
| `target_window.min_yd` | number | yes, within | - | no | as stored |
| `updated_at` | string | yes | - | no | as stored |

## What it holds

One session is one day at the range, or one visit. It holds the date, a place name typed by the shooter, a line of notes, the device that made it, and optionally a window of ranges for guided calibration. Every logged group belongs to one session. The planned targets of the session are on [session targets](session-targets.md); the groups are on [group record](observation.md). Unlike a profile version, a session is changed in place.

## What to know first

**A session is changed in place, not versioned.** The app renames it and edits its targets. The connector cannot change one. The app never deletes one. A deleted session leaves the lists and shared views; its groups stay in your own fits.

**The date is a plain day, but each writer chooses the day its own way.** The app uses the phone's local day. The connector uses the UTC day, so a voice session can carry another day than the phone's.

**One session at a time is the active log:** the one made, logged into or picked as active most recently, if that was under 12 hours ago. "Log this shot" and `log_group` both put a shot there, whatever its date or place; with none active each starts a session.

**The place name is a key.** Copying targets from the last visit matches names exactly, so "North range" and "North range " do not match.

## The fields

**`id`**, **`created_at`**, **`updated_at`**, **`deleted`**, **`owner_id`** The common frame, as on a [group record](observation.md): a time-ordered id made by whoever made the session (the device, or the connector), ISO times in UTC (equal when the session is made), a soft-delete flag and the owning account, which the server sets. `updated_at` is set to the device clock by a rename and by a change of targets. Nothing else reads it: sync does not compare it.

**`date`** A calendar day, written `YYYY-MM-DD`. Required. The app writes the phone's local day. `start_session` and `log_group` write the day in UTC from the connector's clock (run for this page: at 04:30 UTC on 16 October, the connector wrote 2026-10-16 and a phone set to US Central time wrote 2026-10-15). East of UTC it is the other way: a session made by voice in the morning carries yesterday's date. The session lists sort by this text, newest first. The Calculator's "Other log" list shows the sessions whose date equals the phone's local day, so a session made by voice after midnight UTC does not appear there that evening; "Log this shot" itself goes by the active log, not by the date. Example: 2026-10-15.

**`location_name`** A place name, free text typed by the shooter. Required; the app saves "Unnamed range" when the box is empty. The connector's `start_session` saves what it is given, and `log_group` saves "Logged by voice". It is a label on the lists, in the labels of a calibration's per-session velocity offsets, and a match key for copying targets from the last visit. Example: Thunder Valley.

**`notes`** Free text, required (empty is fine). A new session from the app starts with an empty line; `start_session` can set it, and since 2026-10-24 the Session screen shows it under the name with "Add notes" or "Edit notes" (trimmed, at most 2,000 characters). A shared log shows it too. Example: Cold start, light mist.

**`device_id`** The id of the device that made the session: the phone's own id, or the word "claude" for a session made by the connector. It is only a record of the origin. No screen, tool or fit reads it, and a shared log shows it empty.

**`target_window`**, with `target_window.min_yd`, `target_window.max_yd` (both required, in yards), `target_window.inclination_deg` and `target_window.azimuth_true_deg` (optional, in degrees) An optional set of ranges for guided calibration. Guided calibration reads it only when the session has no planned targets. It then offers one candidate target every 100 yd from `min_yd` up to `max_yd`, all with the window's angles, so a window of 300 to 2,550 yd offers 300 to 2,500 yd. Nothing in the app, the connector or sync makes one today: only a record from a backup or from outside carries one. Since 2026-10-24 the app holds it to 0 to 10,000 yd, its ends in order, both where guided calibration reads it and whenever the app saves the session; a candidate at 0 yd is never offered, so a window gives at most 100. Sync checks no limit. Example: min_yd 300, max_yd 2500.

**`measured_mv`**, with `measured_mv.profile_id` (required, at most 80 characters), `measured_mv.mode` (required: `all` or `latest`), `measured_mv.on` (required) and `measured_mv.asked` (optional). An optional switch: solve with the velocity measured in this session instead of the profile's stored one. It applies only while the named profile is the one being solved; for any other profile the session acts as if the block were absent. `all` takes every shot of the session's groups logged with that profile, plus the shots in the open group form. `latest` takes only the most recent group that has a usable velocity, or the open form when it has shots: a newer group with none (not measured, or only shots out of bounds) is passed over, not counted as empty. A group with only a mean counts that mean once per shot for the average and adds nothing to SD or ES; a group with only a mean and a shot count of 0 (a voice group logged without a count) adds nothing, so log the count with the velocity; a group marked as not measured adds nothing. A shot under 100 or over 6,000 fps is dropped without a word, and a group whose listed shots are all out of those bounds adds nothing: its stored mean is not used in their place. `on` false turns the switch off without forgetting it. `asked` records that the offer was answered, so it is not made again in this session for that profile. The velocity itself is never stored: every reader works it out from the groups with one shared function, so two readers cannot disagree. A new session starts without the block. Example: profile_id p-1, mode all, on true, asked true.

**Limits, by writer.** The schema holds no number limits for these fields, and the profile limits table does not apply.

- App: no limit in the code on the name; the Session screen's notes box takes at most 2,000 characters (since 2026-10-24). The Calculator and a rename trim the name and save "Unnamed range" for an empty one; "Start session" on the Range log does not trim, so a name of spaces is saved as spaces.
- Connector, `start_session`: `location_name` 1 to 80 characters (0 and 81 are refused, 80 is saved); `notes` at most 500 (501 is refused). It does not trim spaces. `log_group` writes a fixed name. `start_session` always makes a new session and never reuses one; pass the `session_id` it returns to `log_group`, which otherwise picks by the rule above.
- Date: no writer checks it. A date of "banana", an empty date or a date in 2099 is accepted by sync. Both screens and the connector write today's day.
- `target_window`: sync requires `min_yd` and `max_yd` when the block is there; it does not check that the minimum is below the maximum or that either is positive. A minimum above the maximum offers nothing; a negative minimum offers candidates at negative ranges.
- Sync: the whole record must fit in 64 kB and the account in 20,000 records of all kinds, deleted ones included.

## Who writes it

**The app.** The Calculator's "Log this shot" (and "Record a group" beside it, since 2026-10-19, which does the same and puts the cursor in the range) makes a session when no log is active (named after the place of the newest session by date, else "Unnamed range"), its "Other log" list makes one with "New session", and the Range log screen makes one with "Start session", or with "Log a group" when no session is dated today (since 2026-10-22; with one dated today, that button opens it, the active one first, and makes nothing). "Make active" on the Session screen writes no field of the session: the pick, a session id and a time, is kept in the account's display preferences and follows the account to every device. Both write an empty `notes` and the phone's id. On the Session screen the shooter can rename the session and change its targets; each of those saves the whole record again, after reading it afresh so an edit from another device is not undone. The Calculator writes `measured_mv` when the shooter answers the measured-velocity offer ("Use measured" or "Not now") or flips the line's switch; it saves the whole record as the Calculator holds it, without reading it afresh, so a rename made on another device and not yet synced can be undone by it.

**The connector.** `start_session` makes a session. `log_group` makes one when it finds none. Both write `device_id` "claude". The connector can make a session and cannot rename, edit or delete one.

**Other paths.** A restored backup writes each session as the file held it. Sync copies a session between devices. The server sets `owner_id`. The fitter writes nothing here.

## Who reads it

**The fitter does not read a session.** It reads each group's `session_id`. A full fit adds one velocity offset for each session that has used groups (see [calibration](../profile/calibration.md)). The `date` and `location_name` only label that row in the app.

**The app.** `listSessions` returns the live sessions, newest date first. The Range log lists them with date and place. The Range log marks the active log, and the Session screen says "Active" or offers "Make active". The Calculator names the active log under "Log this shot"; its "Other log" list offers the sessions of today, and fills the name box of a new session with the newest session's place, so after a voice session it offers "Logged by voice". The Session screen loads one by id. It does not filter deleted sessions, so a link to a deleted session still opens it. The guided calibration reads `target_window` for its candidates, as described under "The fields". The Calculator reads the active log's `measured_mv` and groups (plus the shots in a group form still open on the Session screen) to offer, show and solve with today's measured velocity.

**The connector.** `list_sessions` returns up to 50 live sessions (default 10), newest date first, then newest creation, both compared as text. `get_session` returns one live session with its live groups, at most 300 of them, the newest (with `truncated` when it cut); a deleted or unknown id gives "No session". Since 2026-10-15 both leave out `owner_id` and put the typed text (place name, notes, date, device id, target names) under `stored_text`, beside a note that it is not an instruction. Since 2026-10-16 `get_session` also gives the scope unit of each profile its groups were shot with (`scope_units`), and each angle of a group on an MOA profile in MOA too. `solve`, `range_card` and `get_context` read the active log's `measured_mv` and its groups for today's measured velocity, by the same rule `log_group` uses to find the log. `log_group` reads up to 100 sessions, the time of each one's newest group and the account's pick, and logs into the active log: the session whose latest of made, newest group and picked is the most recent, if that is under 12 hours ago; the date plays no part. A time more than five minutes in the future does not count (five allows for a phone clock that runs fast). If none is active, it makes one. The app decides with the same function on the same three facts.

**The server.** The Community page shows the number of sessions of each listed public log. The admins' pool is sent each pooled log as its share filters it. The admins' file export carries the session's date and its per-share id, nothing else of the session. The filter is described under "Sharing and privacy".

**Exports.** The backup file holds whole sessions. The CSV export names the group's session by id only.

## Why it is stored

A session ties the groups of one outing together, which is how a full fit gives each day its own muzzle-velocity offset. The date and the place let a person find the outing again and let the targets of a past visit be copied. The notes hold what the fields do not. `device_id` records where the session was made. `target_window` lets guided calibration suggest ranges before any target is planned.

## Changing it

**A session is not versioned.** It is one record changed in place. The only changes today are a rename and a change of targets in the app, each setting `updated_at` to the device clock; the connector changes nothing on a saved session. The app has no delete for a session, so a delete can come only from a restored backup or another client.

**Two devices.** As for a group: the server keeps the whole record from the latest push that reaches it, never compares `updated_at` and never merges fields. A stored deleted session cannot be brought back; a later push of it as not deleted is answered as stale and the device takes the deleted copy. A rename on one device and a change of targets on another therefore do not combine: the later push wins both fields.

**What a delete does.** The record stays, marked deleted. After it:

| Reader | A deleted session |
|---|---|
| Range log, Calculator list | gone |
| Session screen by its link | still opens |
| `list_sessions`, `get_session` | gone; "No session" |
| `log_group` into that id | refused, "No session"; the search for a recent session skips it |
| Shared views and the pooled export | the session and all its groups are gone |
| Your own fits and `calibrate_profile` | its groups are still used; the per-session offset stays and, in the app, takes a plain "Session" label |

The groups are not deleted with their session. To correct a mistake in a session, rename it; there is no way to move a group to another session.

## Sharing and privacy

A shared log shows a session for any reader (one person, the public or the admins) by the same filter, except as listed here. Only sessions that hold a live group of the shared profile are shown.

- The `id` field of a shared session holds an id made for this share. `owner_id` is null. `device_id` is empty.
- `location_name` is empty when the share hides location. A public share hides it unless the sharer turns it on; a share with one person or with the admins shows it, as typed, unless the sharer turns it off.
- `notes` is empty when the share hides notes, which a public share does by default.
- `created_at`, `updated_at`, `deleted` and `target_window` are shown as stored, whatever the switches say, except the window's compass bearing (`target_window.azimuth_true_deg`), which is rounded to the nearest 10 degrees when the share hides location. `date` is shown as stored on a share with one person or the admins; on any other share (public, or an audience the code cannot place) it is replaced by the UTC day of `created_at` since 2026-10-24, or left empty when that time is not in the app's own form. A reader of such a share still gets the window's ranges and its slope. The planned targets are on [session targets](session-targets.md).
- `measured_mv` is removed for every reader, all four of its fields (`measured_mv.profile_id`, `measured_mv.mode`, `measured_mv.on`, `measured_mv.asked`): it is the owner's own switch.
- A deleted session is not shown, and neither are its groups.

The admins' file export carries only the date and the per-share id of each session; the Community page shows a count of sessions.

What a reader can work out: the dates and times show when the person shoots and how often.

---

[All reference pages](../index.md)
