@lotics/app-sdk 0.115.3 → 0.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -14,13 +14,13 @@ This file is the index. The **exact type** of anything is its shipped declaratio
14
14
 
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
- | [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column and `total: { where }` a count per named filter, from one scan — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
17
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column and `total: { where }` a count per named filter, from one scan — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push** and `answeredAt` (the server answering again what a screen drew), a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
18
18
  | [docs/queries.md](./docs/queries.md) | **The query engine reference** — AST node kinds, per-field-type operators, filters/params/pruning, free-text search, combining tables, shaping (aggregates, date buckets, windows), runtime refinement bounds, limits and the efficiency playbook. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path; `useWorkflows` for writes that are data), the `WorkflowResult` resolve-never-throw contract (`field_errors`), typed inputs, every write drawn on every read from the press — predicted from the workflow's own steps, taken back on a refusal, `pending` meanwhile — the re-read a successful write triggers over the tables its body names, diff-before-update, locked records, `useNewRecord`, read-after-write ordering (`writesSettled()`), `useRecording`, `useRecordings` over several aliases. |
20
20
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY reference** — the JS subset a body may use, opaque `fld_*`/`opt_*` keys, every step form, the accepted sugar, helpers, record-write surfaces, the traps and the verify loop. |
21
21
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
22
- | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload` and its `fidelity`, `renameFile` (a new file over the same bytes), `useAttachments`/`useAttachmentPiles`, `readFiles` and presigned URLs (a bearer credential — never logged or persisted), workflow-generated files, naming a zip's entries, the delivery bounds. |
23
- | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`. |
22
+ | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload` and its `fidelity` and a photo's `place`, `renameFile` (a new file over the same bytes), `useAttachments`/`useAttachmentPiles`, `readFiles` and presigned URLs (a bearer credential — never logged or persisted), workflow-generated files, naming a zip's entries, the delivery bounds. |
23
+ | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`, a record's activity (`useRecordActivity`). |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model, the screens and `drawers` it reports to the host), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming parts, the agent's ask-back), `askAi`, `useAiContext`, and what the member's own chat agent can do with the app while it is open. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, why a per-input bound is a tenancy floor rather than an authorization check. |
@@ -0,0 +1,27 @@
1
+ import type { ActivityEntry } from "./shared_types.js";
2
+ export interface RecordActivityState {
3
+ /** Newest first. */
4
+ entries: ActivityEntry[];
5
+ /** Where the trail goes on — pass it as `after` to read the page past this one; `undefined` at its end. */
6
+ next: string | undefined;
7
+ loading: boolean;
8
+ error: string | null;
9
+ /** A signed-in member; standalone nothing is read and `entries` stays empty. */
10
+ available: boolean;
11
+ refetch: () => void;
12
+ }
13
+ export interface RecordActivityOptions {
14
+ /** A page's `next`: read the page past it. Absent, the newest page. */
15
+ after?: string;
16
+ }
17
+ /**
18
+ * One page of the record's activity. `entity` is the alias the app's spec names the record's entity under; `record_id`
19
+ * a real id. Read again when a push or this app's own write names the entity's reads, and when a comment is written.
20
+ *
21
+ * ```tsx
22
+ * const { entries, next } = useRecordActivity("job", recordId);
23
+ * // The page past it, mounted where the reader asks for more:
24
+ * const older = useRecordActivity("job", recordId, { after: next });
25
+ * ```
26
+ */
27
+ export declare function useRecordActivity(entity: string, record_id: string, options?: RecordActivityOptions): RecordActivityState;
@@ -471,7 +471,8 @@ async function runUploadPipeline(file, rpc2, options = {}) {
471
471
  const { file: uploaded } = await rpc2.completeUpload({
472
472
  file_id: init.file_id,
473
473
  file_storage_key: init.file_storage_key,
474
- filename: candidate.name
474
+ filename: candidate.name,
475
+ ...options.place === void 0 ? {} : { place: options.place }
475
476
  });
476
477
  return uploaded;
477
478
  }
@@ -954,7 +955,8 @@ function rpcStandalone(op, payload) {
954
955
  case "upload":
955
956
  return standaloneUpload(
956
957
  payload.file,
957
- payload.fidelity
958
+ payload.fidelity,
959
+ payload.place
958
960
  );
959
961
  case "file.rename":
960
962
  return standaloneRename(payload);
@@ -988,6 +990,8 @@ function rpcStandalone(op, payload) {
988
990
  case "comments.delete":
989
991
  case "comments.counts":
990
992
  return rejectCommentsStandalone();
993
+ case "activity.list":
994
+ return Promise.reject(new Error("A record's activity is available only in embedded apps \u2014 a signed-in member is required."));
991
995
  case "recording.start":
992
996
  case "recording.stop":
993
997
  return Promise.reject(
@@ -1169,7 +1173,7 @@ async function standaloneRename(payload) {
1169
1173
  const { file } = await apiCall("POST", `/v1/apps/${app_id}/files/${encodeURIComponent(fileId)}/rename`, { filename }, { appId: app_id });
1170
1174
  return file;
1171
1175
  }
1172
- async function standaloneUpload(file, fidelity) {
1176
+ async function standaloneUpload(file, fidelity, place) {
1173
1177
  if (!(file instanceof File)) {
1174
1178
  throw new Error("upload payload must include a File");
1175
1179
  }
@@ -1187,7 +1191,7 @@ async function standaloneUpload(file, fidelity) {
1187
1191
  input,
1188
1192
  { appId: app_id }
1189
1193
  )
1190
- }, { fidelity });
1194
+ }, { fidelity, ...place === void 0 ? {} : { place } });
1191
1195
  return uploaded;
1192
1196
  }
1193
1197
 
@@ -51,6 +51,8 @@ export interface CommentsState {
51
51
  export interface UseCommentsArgs {
52
52
  record_id: string;
53
53
  }
54
+ /** What every read a comment changes is read under — the counts, a record's activity — so a comment written or deleted asks each again. */
55
+ export declare const COMMENT_WRITES = "comment-counts";
54
56
  /**
55
57
  * `record_id` must be a real id — an empty string still fetches. Gate the
56
58
  * panel on a row's `__source_record_id`, which a grouped query answers as null.
@@ -1,8 +1,3 @@
1
- /**
2
- * Geofenced device location, read directly (the host grants the iframe the
3
- * `geolocation` Permissions-Policy). Failure is a structured `reason`, so the
4
- * app words its own guidance.
5
- */
6
1
  /** Center `[latitude, longitude]`, radius in meters. */
7
2
  export interface GeofenceZone {
8
3
  coordinates: [number, number];
@@ -28,6 +23,9 @@ export type GeofenceOutcome = {
28
23
  export interface GeofenceOptions {
29
24
  /** Default 15000. */
30
25
  timeoutMs?: number;
26
+ /** The device's best fix (satellites where it has them), slower and costlier than its default — for a place a
27
+ * record keeps, where a fix a block off reads as somewhere else. Default false. */
28
+ highAccuracy?: boolean;
31
29
  }
32
30
  export declare function isWithinZone(latitude: number, longitude: number, zone: GeofenceZone): boolean;
33
31
  /**
package/dist/hooks.d.ts CHANGED
@@ -4,6 +4,7 @@ import { type AiContextValue } from "./rpc.js";
4
4
  import { type AgentUIPart, type PendingChoice, type AgentRunLanding } from "./agent_stream.js";
5
5
  import type { AppWorkflows, AppWorkflowResults, AppAgents, AppAgentResults } from "./types.js";
6
6
  import type { ResolvedMember } from "./members.js";
7
+ import type { PhotoPlace } from "./row.js";
7
8
  export type { AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, AgentRunLanding } from "./agent_stream.js";
8
9
  export { buildChoiceOutput } from "./agent_stream.js";
9
10
  /**
@@ -66,6 +67,7 @@ interface FileUploadState {
66
67
  */
67
68
  upload: (file: File, options?: {
68
69
  fidelity?: ImageFidelity;
70
+ place?: PhotoPlace;
69
71
  }) => Promise<UploadedFile>;
70
72
  uploading: boolean;
71
73
  /** Cleared when a new upload starts. */
package/dist/index.d.ts CHANGED
@@ -6,12 +6,16 @@ export type { AppErrorKind } from "./error_report.js";
6
6
  export { useWorkflow, useWorkflows, writesSettled, useFileUpload, useAttachments, useAttachmentPiles, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
7
7
  export type { UploadedFile, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, } from "./hooks.js";
8
8
  export { useQuery, useQueries, queryAll, exportQuery, useFieldOptions } from "./queries.js";
9
- export type { QueryRow, QueryCall, QueryState, RowOf, QueryOptions, ColumnKeyOf, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, FieldOptions, FieldOptionsState, FieldOptionsOptions, FigureUnits, ExportCall, ExportFile, ExportReport, ExportTab, } from "./queries.js";
9
+ export type { QueryRow, QueryCall, QueryState, RowOf, QueryOptions, ColumnKeyOf, QuerySortKey, QueryFieldSortKey, QueryFirstSortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, FieldOptions, FieldOptionsState, FieldOptionsOptions, FigureUnits, ExportCall, ExportFile, ExportReport, ExportTab, } from "./queries.js";
10
10
  export type { ExportedFile } from "./shared_types.js";
11
11
  export type { AttachedFile, AttachmentPiles, AttachmentPilesOptions, AttachmentsOptions, AttachmentsState } from "./attachments.js";
12
12
  export { useComments, useCommentCounts } from "./comments.js";
13
13
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
14
+ export { useRecordActivity } from "./activity.js";
15
+ export type { RecordActivityOptions, RecordActivityState } from "./activity.js";
16
+ export type { ActivityActor, ActivityActorKind, ActivityChange, ActivityComment, ActivityEntry, ActivityValue, RecordActivityPage, } from "./shared_types.js";
14
17
  export { useAppContext, useViewer, useWorkspaceCurrency, useWorkspaceTimezone } from "./viewer.js";
18
+ export type { AppAbsence } from "./shared_types.js";
15
19
  export { useRecording, useRecordings } from "./recording.js";
16
20
  export type { UseRecording, UseRecordings } from "./recording.js";
17
21
  export type { RecordingState, RecordingInputs } from "./recording_state.js";
@@ -32,7 +36,7 @@ export type { AppFixture, MockExport, MockQuery, MockQueryCall, MockWorkflow } f
32
36
  export type { MockAgent, MockAgentRun } from "./mock_agent_run.js";
33
37
  export type { AppWorkflows, AppWorkflowResults, AppQueries, AppQueryColumns, AppAgents, AppAgentResults } from "./types.js";
34
38
  export { row, readLinks, readFiles, readLocked, readCreatedAt, readUpdatedAt, PENDING } from "./row.js";
35
- export type { ResolvedLink, AppFile } from "./row.js";
39
+ export type { ResolvedLink, AppFile, PhotoPlace } from "./row.js";
36
40
  export { useNewRecord, newRecordId } from "./new_record.js";
37
41
  export type { NewRecordApi } from "./new_record.js";
38
42
  export { useRecents } from "./use_recents.js";
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  subscribeRecordings,
26
26
  subscribeUrlParams,
27
27
  urlParam
28
- } from "./chunk-EWCMZCKA.js";
28
+ } from "./chunk-IN4WP24U.js";
29
29
 
30
30
  // src/mount.tsx
31
31
  import { createRoot } from "react-dom/client";
@@ -601,6 +601,9 @@ function useKey(key, fetcher, opts = {}) {
601
601
  const arrival = opts.arrival ?? true;
602
602
  const aliases = opts.aliases === void 0 ? NO_ALIASES : new Set(opts.aliases);
603
603
  const slot = key === null ? null : slotOf(key, fetcher, aliases);
604
+ const mounted = useRef(null);
605
+ if (mounted.current === null || mounted.current.key !== key) mounted.current = { key, at: clock };
606
+ const mountedAt = mounted.current.at;
604
607
  const seeded = useRef(null);
605
608
  if (slot !== null && key !== null && slot.askedAt === 0 && opts.known !== void 0) {
606
609
  const askedAt = tick();
@@ -663,6 +666,10 @@ function useKey(key, fetcher, opts = {}) {
663
666
  read2.add("answer");
664
667
  return kept?.askedAt ?? 0;
665
668
  },
669
+ get answeredAt() {
670
+ read2.add("answer");
671
+ return current !== void 0 && current.askedAt > mountedAt ? current.askedAt : 0;
672
+ },
666
673
  get error() {
667
674
  read2.add("error");
668
675
  return slot?.error ?? null;
@@ -20633,6 +20640,11 @@ var fileUploadResponseSchema = zod_default.object({
20633
20640
  files: zod_default.array(processedFileSchema).describe("Successfully uploaded files"),
20634
20641
  errors: zod_default.array(fileUploadErrorSchema).describe("Files that failed to upload with error details")
20635
20642
  });
20643
+ var filePlaceSchema = zod_default.object({
20644
+ latitude: zod_default.number().min(-90).max(90),
20645
+ longitude: zod_default.number().min(-180).max(180),
20646
+ accuracy: zod_default.number().min(0)
20647
+ }).strict();
20636
20648
  var fileFieldValueSchema = zod_default.object({
20637
20649
  id: zod_default.string().describe("Unique identifier for the file"),
20638
20650
  /**
@@ -24096,6 +24108,7 @@ var KIT_GLYPH_NAMES = [
24096
24108
  "rotate-cw",
24097
24109
  "rows-3",
24098
24110
  "scan",
24111
+ "scan-line",
24099
24112
  "scissors",
24100
24113
  "search",
24101
24114
  "send",
@@ -25581,15 +25594,19 @@ var LINKED_CALL = "linked";
25581
25594
  var GRAMMAR_NAME_SHAPE = `${LINKED_CALL}(record["fld_link"])[0] fetches one linked row`;
25582
25595
  var RUNTIME_KEYS = new Set(runtimeKeySchema.options);
25583
25596
  var CURRENT_MEMBER_IN_ANY_GROUP = "current_member_in_any_group";
25597
+ var CURRENT_MEMBER_STANDS_IN_FOR = "current_member_stands_in_for";
25598
+ var CURRENT_MEMBER_STANDS_IN_FOR_ANY_GROUP = "current_member_stands_in_for_any_group";
25584
25599
  var INLINE_HELPERS = [
25585
25600
  "formatCurrency",
25586
25601
  "randomNumber",
25587
25602
  "randomAlphaNumeric",
25588
25603
  "sample",
25589
- // Actor authorization predicate — resolves the triggering member's groups
25590
- // server-side (bound in the backend helper registry, not a pure shared
25591
- // helper).
25592
- CURRENT_MEMBER_IN_ANY_GROUP
25604
+ // Actor authorization predicates — resolve the triggering member's groups and
25605
+ // whom they stand in for server-side (bound in the backend helper registry,
25606
+ // not pure shared helpers).
25607
+ CURRENT_MEMBER_IN_ANY_GROUP,
25608
+ CURRENT_MEMBER_STANDS_IN_FOR,
25609
+ CURRENT_MEMBER_STANDS_IN_FOR_ANY_GROUP
25593
25610
  ];
25594
25611
  var HELPERS = /* @__PURE__ */ new Set([
25595
25612
  ...Object.keys(expression_array_exports),
@@ -25744,6 +25761,9 @@ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
25744
25761
  var PREFIX_CACHE_TTLS = ["5m", "1h"];
25745
25762
 
25746
25763
  // ../shared/src/schemas/apps.ts
25764
+ var QUERY_FIRST_MAX = 8;
25765
+ var queryFirstSortKeySchema = zod_default.object({ first: zod_default.array(tableRecordFiltersSchema).min(1).max(QUERY_FIRST_MAX) }).strict();
25766
+ var querySortSchema = zod_default.array(zod_default.union([tableRecordSortSchema.element, queryFirstSortKeySchema]));
25747
25767
  var queryColumnTypeSchema = zod_default.enum([
25748
25768
  "text",
25749
25769
  "number",
@@ -26308,6 +26328,9 @@ var appThemeSchema = zod_default.object({
26308
26328
  var appCapabilitiesSchema = zod_default.object({
26309
26329
  comments: zod_default.boolean().optional().describe(
26310
26330
  "Enable the members-only `useComments` primitive. When true, the app may read/write record comments \u2014 each operation under the VIEWING member's own table access, row-scope, and author identity (never the app owner's). Default off."
26331
+ ),
26332
+ photo_places: zod_default.boolean().optional().describe(
26333
+ "Serve each photo's `place` \u2014 where the device stood as it took it, as its upload stated \u2014 on the file cells this app's reads return, to a member of the app's organization only; a caller outside it never receives one. Default off: no read carries a place."
26311
26334
  )
26312
26335
  });
26313
26336
  var appSchema = zod_default.object({
@@ -28622,8 +28645,10 @@ function clientRuntime(input) {
28622
28645
  async function predictWorkflowEffects(input) {
28623
28646
  const helpers = {
28624
28647
  ...createTimezoneAwareFunctions(input.timezone),
28625
- // Group membership is the server's to answer.
28648
+ // Group membership and whom the caller stands in for are the server's to answer.
28626
28649
  current_member_in_any_group: () => unread("current_member_in_any_group"),
28650
+ current_member_stands_in_for: () => unread("current_member_stands_in_for"),
28651
+ current_member_stands_in_for_any_group: () => unread("current_member_stands_in_for_any_group"),
28627
28652
  [WITHHELD]: () => unread(WITHHELD)
28628
28653
  };
28629
28654
  const written = /* @__PURE__ */ new Map();
@@ -28889,6 +28914,8 @@ function useAppContext() {
28889
28914
  const { data, error: error51, refetch } = useKey(APP_CONTEXT_KEY, readContext, { focus: false, arrival: false });
28890
28915
  return {
28891
28916
  memberId: data?.member_id ?? null,
28917
+ groupIds: data?.group_ids ?? null,
28918
+ absences: data?.absences ?? null,
28892
28919
  commentsEnabled: data?.comments_enabled ?? false,
28893
28920
  recordingEnabled: data?.recording_enabled === true,
28894
28921
  timezone: data?.timezone,
@@ -28906,7 +28933,7 @@ function useWorkspaceCurrency() {
28906
28933
  }
28907
28934
  function useViewer() {
28908
28935
  const ctx = useAppContext();
28909
- return { memberId: ctx.memberId, loading: !ctx.settled };
28936
+ return { memberId: ctx.memberId, groupIds: ctx.groupIds, loading: !ctx.settled };
28910
28937
  }
28911
28938
 
28912
28939
  // src/written.ts
@@ -29129,12 +29156,12 @@ function noteAnswer(key, alias, askedAt, rows, since = askedAt) {
29129
29156
  if (shape === void 0) return;
29130
29157
  noteRows(shape, rows, askedAt);
29131
29158
  judgePredictions(shape, rows, askedAt);
29132
- answered.set(JSON.stringify([key, alias]), { key, askedAt: since, tables: tablesOf(shape) });
29133
29159
  for (const [at2, one] of answered) if (one.key !== key && !mountedKey(one.key)) answered.delete(at2);
29134
29160
  const before = entries2.length;
29135
29161
  entries2 = entries2.filter(
29136
29162
  (entry) => entry.settledAt === null || [...answered.values()].some(({ askedAt: at2, tables }) => at2 <= (entry.settledAt ?? 0) && touches(entry, tables))
29137
29163
  );
29164
+ answered.set(JSON.stringify([key, alias]), { key, askedAt: since, tables: tablesOf(shape) });
29138
29165
  if (entries2.length !== before) publish();
29139
29166
  }
29140
29167
  function comparable(field, value) {
@@ -29334,7 +29361,7 @@ function useFileUpload() {
29334
29361
  setInFlight((n) => n + 1);
29335
29362
  setError(null);
29336
29363
  try {
29337
- const uploaded = await rpc("upload", { file: file2, fidelity });
29364
+ const uploaded = await rpc("upload", { file: file2, fidelity, ...options?.place === void 0 ? {} : { place: options.place } });
29338
29365
  return uploaded;
29339
29366
  } catch (err) {
29340
29367
  const message2 = err instanceof Error ? err.message : "Upload failed";
@@ -29661,6 +29688,13 @@ function readLinks(v) {
29661
29688
  }
29662
29689
  return out;
29663
29690
  }
29691
+ function placeOf(value) {
29692
+ if (typeof value !== "object" || value === null) return void 0;
29693
+ const latitude = Reflect.get(value, "latitude");
29694
+ const longitude = Reflect.get(value, "longitude");
29695
+ const accuracy = Reflect.get(value, "accuracy");
29696
+ return typeof latitude === "number" && typeof longitude === "number" && typeof accuracy === "number" ? { latitude, longitude, accuracy } : void 0;
29697
+ }
29664
29698
  function readFiles(v) {
29665
29699
  if (!Array.isArray(v)) return [];
29666
29700
  const out = [];
@@ -29679,6 +29713,7 @@ function readFiles(v) {
29679
29713
  const size2 = f.size;
29680
29714
  const created_at = f.created_at;
29681
29715
  const template = f.document_template_id;
29716
+ const place = placeOf(f.place);
29682
29717
  out.push({
29683
29718
  id,
29684
29719
  filename: typeof filename === "string" ? filename : "",
@@ -29687,7 +29722,8 @@ function readFiles(v) {
29687
29722
  thumbnail_url: typeof thumb === "string" ? thumb : void 0,
29688
29723
  size: typeof size2 === "number" ? size2 : void 0,
29689
29724
  created_at: typeof created_at === "string" ? created_at : void 0,
29690
- document_template_id: typeof template === "string" ? template : void 0
29725
+ document_template_id: typeof template === "string" ? template : void 0,
29726
+ ...place === void 0 ? {} : { place }
29691
29727
  });
29692
29728
  }
29693
29729
  }
@@ -31520,33 +31556,37 @@ function buildDependencyGraph(formulaFields, allFields) {
31520
31556
  }
31521
31557
  return { sortedFields: result, circularDependencies };
31522
31558
  }
31523
- function readsFieldsOnlyThroughEmptinessPredicates(node) {
31559
+ function readsFieldsOnlyThroughEmptinessPredicates(node, taken) {
31560
+ const reads = (child) => readsFieldsOnlyThroughEmptinessPredicates(child, taken);
31524
31561
  if (node === void 0) return true;
31525
31562
  if (isFieldRead(node)) return false;
31526
31563
  switch (node.type) {
31527
31564
  case "Invoke": {
31528
31565
  const args = node.arguments ?? [];
31529
31566
  const predicate = node.method === void 0 && node.receiver.type === "ID" && EMPTINESS_PREDICATES.has(node.receiver.value);
31530
- return (predicate || readsFieldsOnlyThroughEmptinessPredicates(node.receiver)) && args.every(
31531
- (arg) => predicate && isFieldRead(arg) || readsFieldsOnlyThroughEmptinessPredicates(arg)
31567
+ return (predicate || reads(node.receiver)) && args.every(
31568
+ (arg) => predicate && isFieldRead(arg) || reads(arg)
31532
31569
  );
31533
31570
  }
31534
31571
  case "Unary":
31535
- return readsFieldsOnlyThroughEmptinessPredicates(node.child);
31572
+ return reads(node.child);
31536
31573
  case "Binary":
31537
- return readsFieldsOnlyThroughEmptinessPredicates(node.left) && readsFieldsOnlyThroughEmptinessPredicates(node.right);
31574
+ return reads(node.left) && reads(node.right);
31538
31575
  case "Getter":
31539
- return readsFieldsOnlyThroughEmptinessPredicates(node.receiver);
31576
+ return reads(node.receiver);
31540
31577
  case "Index":
31541
- return readsFieldsOnlyThroughEmptinessPredicates(node.receiver) && readsFieldsOnlyThroughEmptinessPredicates(node.argument);
31542
- case "Ternary":
31543
- return readsFieldsOnlyThroughEmptinessPredicates(node.condition) && readsFieldsOnlyThroughEmptinessPredicates(node.trueExpr) && readsFieldsOnlyThroughEmptinessPredicates(node.falseExpr);
31578
+ return reads(node.receiver) && reads(node.argument);
31579
+ case "Ternary": {
31580
+ if (!reads(node.condition)) return false;
31581
+ const decided = node.condition === void 0 || taken === void 0 ? void 0 : taken(node.condition);
31582
+ return decided === true ? reads(node.trueExpr) : decided === false ? reads(node.falseExpr) : reads(node.trueExpr) && reads(node.falseExpr);
31583
+ }
31544
31584
  case "List":
31545
- return (node.items ?? []).every(readsFieldsOnlyThroughEmptinessPredicates);
31585
+ return (node.items ?? []).every((item) => reads(item));
31546
31586
  case "Map":
31547
- return Object.values(node.entries ?? {}).every(readsFieldsOnlyThroughEmptinessPredicates);
31587
+ return Object.values(node.entries ?? {}).every((entry) => reads(entry));
31548
31588
  case "ArrowFunction":
31549
- return readsFieldsOnlyThroughEmptinessPredicates(node.body);
31589
+ return reads(node.body);
31550
31590
  case "ID":
31551
31591
  return node.value !== "data";
31552
31592
  case "Literal":
@@ -31559,8 +31599,13 @@ function isFieldRead(node) {
31559
31599
  }
31560
31600
  function evaluateFormulaWithJS(preparedExpression, data, timezone, everyReferenceEmpty) {
31561
31601
  try {
31562
- if (everyReferenceEmpty && !readsFieldsOnlyThroughEmptinessPredicates(parseExpression(preparedExpression))) {
31563
- return null;
31602
+ if (everyReferenceEmpty) {
31603
+ const helpers = createTimezoneAwareFunctions(timezone);
31604
+ const taken = (condition2) => {
31605
+ const answer = condition2.evaluate({ ...data, ...helpers });
31606
+ return typeof answer === "boolean" ? answer : void 0;
31607
+ };
31608
+ if (!readsFieldsOnlyThroughEmptinessPredicates(parseExpression(preparedExpression), taken)) return null;
31564
31609
  }
31565
31610
  const result = evaluateJsExpression(preparedExpression, data, void 0, timezone);
31566
31611
  if (result === null || result === void 0) {
@@ -31888,6 +31933,7 @@ function ordered(one, other) {
31888
31933
  }
31889
31934
  function orderOf(shape, tableId, sort) {
31890
31935
  const caller = (sort ?? []).map((key) => {
31936
+ if ("first" in key) return null;
31891
31937
  const fieldKey = fieldKeyOf(shape, key.field_key, tableId);
31892
31938
  return fieldKey === void 0 ? null : { field_key: fieldKey, order: key.order, blanks: key.blank_position ?? "bottom" };
31893
31939
  });
@@ -31898,7 +31944,7 @@ function orderOf(shape, tableId, sort) {
31898
31944
  function isBlank(value) {
31899
31945
  return value === null || value === void 0 || value === "" || Array.isArray(value) && value.length === 0;
31900
31946
  }
31901
- function placeOf(over, rows, cells, order) {
31947
+ function placeOf2(over, rows, cells, order) {
31902
31948
  if (order.length === 0) return 0;
31903
31949
  for (const [at2, row2] of rows.entries()) {
31904
31950
  const id = row2.__source_record_id;
@@ -31963,7 +32009,7 @@ function overlayRows(over, shape, admits, sort, rows, adds) {
31963
32009
  for (const column of Object.keys(shape.columns)) base[column] = null;
31964
32010
  const { row: row2, exact } = redrawn(over, base, shape, rowsTable, after.cells, new Set([...after.cells.keys()].filter((key) => !after.unknown.has(key))));
31965
32011
  if (!exact || after.unknown.size > 0) pending = true;
31966
- out.splice(placeOf(over, out, after.cells, orderOf(shape, rowsTable, sort)), 0, row2);
32012
+ out.splice(placeOf2(over, out, after.cells, orderOf(shape, rowsTable, sort)), 0, row2);
31967
32013
  }
31968
32014
  return { rows: out, pending };
31969
32015
  }
@@ -32264,6 +32310,9 @@ function useQuery(alias, params = {}, opts = {}) {
32264
32310
  get isValidating() {
32265
32311
  return rows.validating || feed.validating || count.validating;
32266
32312
  },
32313
+ get answeredAt() {
32314
+ return !wantsRows ? count.answeredAt : opts.more === void 0 ? rows.answeredAt : feed.answeredAt;
32315
+ },
32267
32316
  error: rows.error?.message ?? feed.error?.message ?? count.error?.message ?? null,
32268
32317
  pending: drawn2.pending || (counted?.pending ?? false),
32269
32318
  refetch,
@@ -32329,6 +32378,7 @@ function useQueries(calls, opts = {}) {
32329
32378
  get isValidating() {
32330
32379
  return answers.validating;
32331
32380
  },
32381
+ answeredAt: answers.answeredAt,
32332
32382
  error: answer?.rows?.error ?? answer?.count?.error ?? answers.error?.message ?? null,
32333
32383
  pending: drawn2.pending || (counted?.pending ?? false),
32334
32384
  refetch: answers.refetch,
@@ -32395,7 +32445,7 @@ function toFileIds(files) {
32395
32445
  return (files ?? []).map((f) => f.id);
32396
32446
  }
32397
32447
  var optimisticCounter = 0;
32398
- var COUNTS = "comment-counts";
32448
+ var COMMENT_WRITES = "comment-counts";
32399
32449
  function useComments(args) {
32400
32450
  const { record_id } = args;
32401
32451
  const { memberId, commentsEnabled, resolved } = useAppContext();
@@ -32430,7 +32480,7 @@ function useComments(args) {
32430
32480
  (current) => ({ comments: [...current?.comments ?? [], optimistic] }),
32431
32481
  () => rpc("comments.create", { record_id, content, file_ids: fileIds.length > 0 ? fileIds : void 0 })
32432
32482
  );
32433
- refetchReads([COUNTS]);
32483
+ refetchReads([COMMENT_WRITES]);
32434
32484
  },
32435
32485
  [available, memberId, record_id, key]
32436
32486
  );
@@ -32456,7 +32506,7 @@ function useComments(args) {
32456
32506
  (current) => ({ comments: (current?.comments ?? []).filter((c) => c.id !== id) }),
32457
32507
  () => rpc("comments.delete", { record_id, comment_id: id })
32458
32508
  );
32459
- refetchReads([COUNTS]);
32509
+ refetchReads([COMMENT_WRITES]);
32460
32510
  },
32461
32511
  [available, record_id, key]
32462
32512
  );
@@ -32477,9 +32527,9 @@ function useCommentCounts(args) {
32477
32527
  const { memberId, commentsEnabled, resolved } = useAppContext();
32478
32528
  const available = memberId != null && commentsEnabled;
32479
32529
  const counted = useKey(
32480
- available && table_id !== void 0 ? JSON.stringify([COUNTS, table_id]) : null,
32530
+ available && table_id !== void 0 ? JSON.stringify([COMMENT_WRITES, table_id]) : null,
32481
32531
  () => rpc("comments.counts", { table_id }),
32482
- { aliases: [COUNTS] }
32532
+ { aliases: [COMMENT_WRITES] }
32483
32533
  );
32484
32534
  return {
32485
32535
  counts: counted.data ?? {},
@@ -32490,6 +32540,36 @@ function useCommentCounts(args) {
32490
32540
  };
32491
32541
  }
32492
32542
 
32543
+ // ../shared/src/app_compile/app_names.ts
32544
+ function listQueryAlias(entity) {
32545
+ return `${entity}_list`;
32546
+ }
32547
+ function oneQueryAlias(entity) {
32548
+ return `${entity}_record`;
32549
+ }
32550
+
32551
+ // src/activity.ts
32552
+ var NO_ENTRIES = [];
32553
+ function useRecordActivity(entity, record_id, options = {}) {
32554
+ const { after } = options;
32555
+ const { memberId, resolved } = useAppContext();
32556
+ const available = memberId !== null;
32557
+ const page = useKey(
32558
+ available ? JSON.stringify(["activity", entity, record_id, after ?? null]) : null,
32559
+ () => rpc("activity.list", { entity, record_id, ...after === void 0 ? {} : { after } }),
32560
+ { aliases: [oneQueryAlias(entity), listQueryAlias(entity), COMMENT_WRITES] }
32561
+ );
32562
+ return {
32563
+ entries: page.data?.entries ?? NO_ENTRIES,
32564
+ next: page.data?.next,
32565
+ // Waiting on the context counts as loading.
32566
+ loading: available ? page.loading : !resolved,
32567
+ error: page.error?.message ?? null,
32568
+ available,
32569
+ refetch: page.refetch
32570
+ };
32571
+ }
32572
+
32493
32573
  // src/recording.ts
32494
32574
  import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
32495
32575
  var NO_MOCKS = {};
@@ -32540,20 +32620,20 @@ function useRecordings(aliases) {
32540
32620
  };
32541
32621
  }
32542
32622
 
32543
- // src/geolocation.ts
32623
+ // ../shared/src/geo_distance.ts
32544
32624
  var EARTH_RADIUS_M = 6371e3;
32545
- function toRadians(degrees) {
32546
- return degrees * Math.PI / 180;
32547
- }
32548
- function distanceMeters(lat1, lon1, lat2, lon2) {
32549
- const dLat = toRadians(lat2 - lat1);
32550
- const dLon = toRadians(lon2 - lon1);
32551
- const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
32552
- return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
32625
+ var radians = (degrees) => degrees * Math.PI / 180;
32626
+ function distanceMeters(a, b) {
32627
+ const dLat = radians(b.latitude - a.latitude);
32628
+ const dLon = radians(b.longitude - a.longitude);
32629
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(a.latitude)) * Math.cos(radians(b.latitude)) * Math.sin(dLon / 2) ** 2;
32630
+ return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
32553
32631
  }
32632
+
32633
+ // src/geolocation.ts
32554
32634
  function isWithinZone(latitude, longitude, zone) {
32555
32635
  const [centerLat, centerLon] = zone.coordinates;
32556
- return distanceMeters(latitude, longitude, centerLat, centerLon) <= zone.radius;
32636
+ return distanceMeters({ latitude, longitude }, { latitude: centerLat, longitude: centerLon }) <= zone.radius;
32557
32637
  }
32558
32638
  async function readPermissionState() {
32559
32639
  if (typeof navigator === "undefined" || !navigator.permissions?.query) return null;
@@ -32564,10 +32644,10 @@ async function readPermissionState() {
32564
32644
  return null;
32565
32645
  }
32566
32646
  }
32567
- function getPosition(timeoutMs) {
32647
+ function getPosition(timeoutMs, highAccuracy) {
32568
32648
  return new Promise((resolve, reject) => {
32569
32649
  navigator.geolocation.getCurrentPosition(resolve, reject, {
32570
- enableHighAccuracy: false,
32650
+ enableHighAccuracy: highAccuracy,
32571
32651
  timeout: timeoutMs,
32572
32652
  maximumAge: 0
32573
32653
  });
@@ -32583,7 +32663,7 @@ async function requestGeofencedLocation(zones, opts) {
32583
32663
  }
32584
32664
  let position;
32585
32665
  try {
32586
- position = await getPosition(timeoutMs);
32666
+ position = await getPosition(timeoutMs, opts?.highAccuracy ?? false);
32587
32667
  } catch (err) {
32588
32668
  const code = err?.code;
32589
32669
  return { ok: false, reason: code === 1 ? "denied" : "unavailable" };
@@ -32849,6 +32929,7 @@ export {
32849
32929
  useQueries,
32850
32930
  useQuery,
32851
32931
  useRecents,
32932
+ useRecordActivity,
32852
32933
  useRecording,
32853
32934
  useRecordings,
32854
32935
  useUrlState,
package/dist/overlay.d.ts CHANGED
@@ -6,11 +6,13 @@ export interface Overlaid<T = readonly Row[]> {
6
6
  /** A write this app made touches this read, and not all of what it changes is drawn. */
7
7
  pending: boolean;
8
8
  }
9
- type SortKeys = readonly {
9
+ type SortKeys = readonly ({
10
10
  field_key: string;
11
11
  order: "asc" | "desc";
12
12
  blank_position?: "top" | "bottom";
13
- }[] | undefined;
13
+ } | {
14
+ first: readonly unknown[];
15
+ })[] | undefined;
14
16
  /**
15
17
  * A read's answer with this app's writes staged over it: a row read's cells, membership and new rows (`adds`:
16
18
  * the first page only), or a declared total's counts and sums.
package/dist/queries.d.ts CHANGED
@@ -25,13 +25,20 @@ export interface QueryRow {
25
25
  export type RowOf<K extends string> = K extends keyof AppQueryColumns ? {
26
26
  [C in AppQueryColumns[K] & string]: unknown;
27
27
  } & Pick<QueryRow, "__source_record_id" | "__source_table_id"> : QueryRow;
28
- /** Applied after the named query, bounded to its projected columns. */
29
- export interface QuerySortKey<C extends string = string> {
28
+ /** A column's order — applied after the named query, bounded to its projected columns. */
29
+ export interface QueryFieldSortKey<C extends string = string> {
30
30
  field_key: C;
31
31
  order: "asc" | "desc";
32
32
  /** Where blank cells sit; `"bottom"` when omitted. */
33
33
  blank_position?: "top" | "bottom";
34
34
  }
35
+ /** The rows each filter keeps, in turn, before the rest — a row with the first it meets, those meeting none last;
36
+ * each part in the keys after. At most 8 filters. */
37
+ export interface QueryFirstSortKey<C extends string = string> {
38
+ first: QueryFilter<C>[];
39
+ }
40
+ /** One key of a read's order; each breaks the ties of the ones before it. */
41
+ export type QuerySortKey<C extends string = string> = QueryFieldSortKey<C> | QueryFirstSortKey<C>;
35
42
  export interface QueryFilterFieldCondition<C extends string = string> {
36
43
  node_type: "condition";
37
44
  field_key: C;
@@ -113,6 +120,12 @@ export interface QueryState<R> {
113
120
  loading: boolean;
114
121
  /** Any request of this read is in flight. */
115
122
  isValidating: boolean;
123
+ /**
124
+ * Moves each time the server answers again a read this screen already drew — a realtime push, a write, a refocus, a
125
+ * feed grown — and only then: never for a write of this app drawn over the rows. 0 while the rows shown predate the
126
+ * screen asking them: the last call's, kept while this one is first asked, or held ones being asked again.
127
+ */
128
+ answeredAt: number;
116
129
  error: string | null;
117
130
  /**
118
131
  * A write this app made is in flight over these rows. They already show what it will change where that
package/dist/router.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  isEmbedded,
3
3
  postHostNotification,
4
4
  setUrlParams
5
- } from "./chunk-EWCMZCKA.js";
5
+ } from "./chunk-IN4WP24U.js";
6
6
 
7
7
  // src/router.tsx
8
8
  import { useEffect } from "react";
package/dist/row.d.ts CHANGED
@@ -52,6 +52,15 @@ export interface AppFile {
52
52
  created_at?: string;
53
53
  /** The document template the file was generated from (`dtl_…`); absent on a file a person brought. */
54
54
  document_template_id?: string;
55
+ /** Where the device that took the photo stood as it took it, as it reported; absent on a file uploaded with none, and
56
+ * on every read of an app not declaring `capabilities.photo_places` or by a caller outside its organization. */
57
+ place?: PhotoPlace;
58
+ }
59
+ /** Where a device stood: degrees north and east, and how many metres off it said it may be. */
60
+ export interface PhotoPlace {
61
+ latitude: number;
62
+ longitude: number;
63
+ accuracy: number;
55
64
  }
56
65
  /**
57
66
  * The presigned files, skipping any without a `url`. A lookup arrives one
package/dist/rpc.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AppAbsence } from "./shared_types.js";
1
2
  import type { AskUserChoiceOutput } from "./agent_stream.js";
2
3
  import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
3
4
  /**
@@ -8,7 +9,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
8
9
  * app → host: { id, op, payload }
9
10
  * host → app: { id, type: "result", data } | { id, type: "error", message }
10
11
  */
11
- export type RpcOp = "query" | "export" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "write_model" | "prediction_miss" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "recording.start" | "recording.stop";
12
+ export type RpcOp = "query" | "export" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "write_model" | "prediction_miss" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "activity.list" | "recording.start" | "recording.stop";
12
13
  export interface AgentRunPayload {
13
14
  alias: string;
14
15
  session_id: string;
@@ -57,6 +58,9 @@ export type HostNotification = {
57
58
  /** The app's identity: the host's context when bridged, `/by-subdomain` (no member) standalone. */
58
59
  export interface AppContext {
59
60
  member_id: string | null;
61
+ /** The member groups the viewer is in, by id — absent from an older host and for a visitor from outside the
62
+ * organization. */
63
+ group_ids?: string[];
60
64
  /** The app declared `comments`; `useComments` also needs a signed-in member. */
61
65
  comments_enabled: boolean;
62
66
  /** The IANA zone every instant is dated in; absent from an older host, which
@@ -70,6 +74,9 @@ export interface AppContext {
70
74
  recording_enabled?: boolean;
71
75
  /** This app's recordings the host still tracks, by alias. */
72
76
  recordings?: Record<string, unknown>;
77
+ /** Who in the organization is away, or will be, and who stands in — absent from an older host and for a visitor from
78
+ * outside the organization. */
79
+ absences?: AppAbsence[];
73
80
  }
74
81
  /** Whether a Lotics host embeds the app, rather than it standing alone on its own origin. */
75
82
  export declare function isEmbedded(): boolean;
@@ -3,6 +3,8 @@
3
3
  * `scripts/build_sdk.mjs`, because that package is private and ships in no
4
4
  * tarball. Written at every build, never edited.
5
5
  */
6
+ export type ActivityEntry = { changes: { field_id: string; label: string; before: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; after: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; removed?: true | undefined; }[]; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "change"; } | { comment: { id: string; body: string; mentions: { id: string; name: string; }[]; files?: { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | undefined; }; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "comment"; };
7
+ export type AppAbsence = { member: { id: string; name: string; }; stand_in: { id: string; name: string; }; starts_on: string; ends_on: string; group_ids: string[]; };
6
8
  /** The file an app export made, as a link to download it: no workspace file, so no id, and kept a day. */
7
9
  export type ExportedFile = {
8
10
  filename: string;
@@ -25,7 +27,7 @@ export type ReportColumn = {
25
27
  key: string;
26
28
  header: string;
27
29
  /** A `date` cell is an ISO day or moment. */
28
- type: "number" | "text" | "date";
30
+ type: "number" | "date" | "text";
29
31
  /** A select whose cells hold bare option keys, no field of the query naming them: the words each key prints as. */
30
32
  labels?: { readonly [x: string]: string; };
31
33
  };
@@ -64,7 +66,7 @@ export type ReportReadingHead = {
64
66
  /** The read behind it was cut: its figures stand, every total over them is left out (`null`). */
65
67
  truncated: boolean;
66
68
  };
67
- export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; } | { key: string; name: string; description: string; type: "text"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; unique?: boolean | undefined; format?: "text" | "link" | "markdown" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "date"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; format?: "date" | "datetime" | "date_range" | "datetime_range" | undefined; timezone?: string | undefined; derive_from?: "created_at" | "updated_at" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; } | { key: string; name: string; description: string; type: "select"; options: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_member"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; display_field_keys: string[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; display_field_widths?: Record<string, number> | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; } | { key: string; name: string; description: string; type: "rollup"; source_field_key: string; aggregate_option: { operation: "count"; field_key?: string | undefined; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique"; field_key: string; } | { operation: "min" | "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range"; field_key: string; } | { operation: "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "earliest" | "latest"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest" | "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest"; field_key: string; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; filter?: TableRecordFiltersGroupNode | undefined; aggregate_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; aggregate_field_format?: string | undefined; aggregate_field_currency?: string | undefined; aggregate_field_unit?: string | undefined; aggregate_field_unit_field?: string | undefined; aggregate_field_currency_field?: string | undefined; } | { key: string; name: string; description: string; type: "lookup"; source_field_key: string; lookup_field_key: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; lookup_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; lookup_field_format?: string | undefined; lookup_field_currency?: string | undefined; lookup_field_unit?: string | undefined; lookup_field_options?: { key: string; name: string; color?: string | undefined; mark?: { kind: string; name: string; } | undefined; }[] | undefined; lookup_field_autonumber?: boolean | undefined; order_by?: { field_key: string; direction: "asc" | "desc"; } | undefined; } | { key: string; name: string; description: string; type: "files"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "formula"; formula: { expression: string; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; link?: true | undefined; format?: "number" | "currency" | "percentage" | "link" | undefined; currency?: string | undefined; unit?: string | undefined; unit_field?: string | undefined; currency_field?: string | undefined; options?: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[] | undefined; output_type?: "number" | "boolean" | "text" | "date" | "datetime" | "select" | undefined; volatile?: boolean | undefined; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "button"; text: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; workflow_id?: string | null | undefined; } | { key: string; name: string; description: string; type: "autonumber"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; prefix?: string | undefined; padding?: number | undefined; template?: string | undefined; };
69
+ export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; } | { key: string; name: string; description: string; type: "text"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; unique?: boolean | undefined; format?: "text" | "link" | "markdown" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "date"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; format?: "date" | "datetime" | "date_range" | "datetime_range" | undefined; timezone?: string | undefined; derive_from?: "created_at" | "updated_at" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; } | { key: string; name: string; description: string; type: "select"; options: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_member"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; display_field_keys: string[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; display_field_widths?: Record<string, number> | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; } | { key: string; name: string; description: string; type: "rollup"; source_field_key: string; aggregate_option: { operation: "count"; field_key?: string | undefined; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique"; field_key: string; } | { operation: "min" | "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range"; field_key: string; } | { operation: "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "earliest" | "latest"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest" | "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest"; field_key: string; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; filter?: TableRecordFiltersGroupNode | undefined; aggregate_field_type?: "number" | "boolean" | "date" | "files" | "text" | "select" | "select_member" | "select_record_link" | "rollup" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; aggregate_field_format?: string | undefined; aggregate_field_currency?: string | undefined; aggregate_field_unit?: string | undefined; aggregate_field_unit_field?: string | undefined; aggregate_field_currency_field?: string | undefined; } | { key: string; name: string; description: string; type: "lookup"; source_field_key: string; lookup_field_key: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; lookup_field_type?: "number" | "boolean" | "date" | "files" | "text" | "select" | "select_member" | "select_record_link" | "rollup" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; lookup_field_format?: string | undefined; lookup_field_currency?: string | undefined; lookup_field_unit?: string | undefined; lookup_field_options?: { key: string; name: string; color?: string | undefined; mark?: { kind: string; name: string; } | undefined; }[] | undefined; lookup_field_autonumber?: boolean | undefined; order_by?: { field_key: string; direction: "asc" | "desc"; } | undefined; } | { key: string; name: string; description: string; type: "files"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "formula"; formula: { expression: string; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; link?: true | undefined; format?: "number" | "currency" | "percentage" | "link" | undefined; currency?: string | undefined; unit?: string | undefined; unit_field?: string | undefined; currency_field?: string | undefined; options?: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[] | undefined; output_type?: "number" | "boolean" | "date" | "text" | "datetime" | "select" | undefined; volatile?: boolean | undefined; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "button"; text: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; workflow_id?: string | null | undefined; } | { key: string; name: string; description: string; type: "autonumber"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; prefix?: string | undefined; padding?: number | undefined; template?: string | undefined; };
68
70
  export type TableRecordFilters = TableRecordFiltersConditionNode | TableRecordFiltersTraversalNode | TableRecordFiltersGroupNode;
69
71
  export type TableRecordFiltersConditionNode = { node_type: "condition"; field_key?: string | undefined; type?: string | undefined; operator: string; value?: unknown; unit_option?: string | undefined; };
70
72
  export type TableRecordFiltersGroupNode = { node_type: "group"; logic: "and" | "or"; children: (TableRecordFilters)[]; };
package/dist/store.d.ts CHANGED
@@ -9,6 +9,12 @@ export interface Held<T> {
9
9
  data: T | undefined;
10
10
  /** When the answer shown was asked; 0 for a staged value or none. */
11
11
  askedAt: number;
12
+ /**
13
+ * When the answer shown was asked, where this mount asked it; 0 while what is shown predates the mount — the last
14
+ * key's answer, or this key's held one being asked again. A value that moves is the server answering again what this
15
+ * mount already drew.
16
+ */
17
+ answeredAt: number;
12
18
  error: Error | null;
13
19
  /** A first answer is awaited, and none is shown. */
14
20
  loading: boolean;
@@ -1,5 +1,6 @@
1
1
  /** Upload: optimize (images) → presigned URL → PUT with retry → complete. */
2
2
  import { type ImageFidelity } from "./optimize.js";
3
+ import type { PhotoPlace } from "../row.js";
3
4
  interface UploadInitResponse {
4
5
  upload_url: string;
5
6
  file_id: string;
@@ -24,6 +25,7 @@ export interface UploadRpc {
24
25
  file_id: string;
25
26
  file_storage_key: string;
26
27
  filename: string;
28
+ place?: PhotoPlace;
27
29
  }): Promise<{
28
30
  file: CompleteResponseFile;
29
31
  }>;
@@ -31,6 +33,8 @@ export interface UploadRpc {
31
33
  export interface RunUploadPipelineOptions {
32
34
  signal?: AbortSignal;
33
35
  fidelity?: ImageFidelity;
36
+ /** A photo's: where the device that took it stood, kept with the file. */
37
+ place?: PhotoPlace;
34
38
  }
35
39
  export declare function runUploadPipeline(file: File, rpc: UploadRpc, options?: RunUploadPipelineOptions): Promise<CompleteResponseFile>;
36
40
  export {};
package/dist/viewer.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AppAbsence } from "./shared_types.js";
1
2
  import { type AppContext } from "./rpc.js";
2
3
  /** The store key the host's context is held under, read by every screen and by the write model alike. */
3
4
  export declare const APP_CONTEXT_KEY = "app-context";
@@ -9,6 +10,10 @@ export declare function readContext(): Promise<AppContext>;
9
10
  */
10
11
  export declare function useAppContext(): {
11
12
  memberId: string | null;
13
+ /** The viewer's member groups, by id; `null` where the host did not say. */
14
+ groupIds: readonly string[] | null;
15
+ /** Who in the organization is away, or will be, and who stands in; `null` where the host did not say. */
16
+ absences: readonly AppAbsence[] | null;
12
17
  commentsEnabled: boolean;
13
18
  recordingEnabled: boolean;
14
19
  timezone: string | undefined;
@@ -32,10 +37,12 @@ export declare function useWorkspaceTimezone(): string | undefined;
32
37
  export declare function useWorkspaceCurrency(): string | undefined;
33
38
  /**
34
39
  * The viewing member — the view-as target under "View as" — or `null` for a
35
- * public visitor, while `loading`, or on failure. Row scoping needs no viewer:
36
- * use an `is_current_member` query filter.
40
+ * public visitor, while `loading`, or on failure; and the member groups they
41
+ * are in (`groupIds`, `null` where the host did not say). Row scoping needs no
42
+ * viewer: use an `is_current_member` query filter.
37
43
  */
38
44
  export declare function useViewer(): {
39
45
  memberId: string | null;
46
+ groupIds: readonly string[] | null;
40
47
  loading: boolean;
41
48
  };
@@ -27,7 +27,7 @@ untyped. It is written from the app's live bindings by `lotics app create --cust
27
27
  | `exportQuery(alias, { params, filter, sort, tabs?, report, file })` | a promise of an `ExportedFile`: `{ filename, mime_type, url, size, created_at }` | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type, labels? }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; `labels` the words each bare option key of a `select` column no field names prints as, refused on any other column and for a key it lacks; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file has no `id` — it is a download, not a workspace file a workflow or a field takes: its `url` is signed for the caller for at most an hour, so open it with `openExternal` when the promise resolves; the server deletes the file a day after it is made |
28
28
 
29
29
  Every hook answers the same `QueryState`: `rows`, `truncated`, `total`, `counts`, `loading`,
30
- `isValidating`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
30
+ `isValidating`, `answeredAt`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
31
31
  `loadMore`, `loadingMore`. A field the call did not ask for rests at its empty value (`page` 0,
32
32
  `total` and `pageCount` undefined, `hasMore` false; `setPage` and `loadMore` do nothing).
33
33
 
@@ -93,7 +93,7 @@ all its calls, and each call states its own `filter`, `sort`, `total` and `rows`
93
93
  |---|---|---|---|
94
94
  | `enabled` | `boolean` | `true` | `false` = no request is sent, `rows` is `[]`, `loading` is `false`. Flip to `true` to fetch. The gate for search-as-you-type and on-demand detail. Disabling also **hides** previously loaded rows (the cached answer survives; it re-renders instantly when re-enabled). |
95
95
  | `revalidateOnFocus` | `boolean` | `true` | `false` = no re-read on window focus / tab return / network reconnect (`refetch()` still works). Keep the default for dashboards; turn off for transient queries (a search bound to an ephemeral term) where a refocus re-run is wasted work and a visible reload. |
96
- | `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns: `[{ field_key, order: "asc" \| "desc", blank_position? }]`. An empty/omitted array leaves the query's own order intact. Part of the read's key — changing it re-queries. |
96
+ | `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns, each key breaking the ties of the ones before: `{ field_key, order: "asc" \| "desc", blank_position? }` a column's order, or `{ first: [filter, …] }` the rows each filter keeps, in turn, before the rest ([queries](./queries.md)). An empty/omitted array leaves the query's own order intact. Part of the read's key — changing it re-queries. |
97
97
  | `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the read's key. |
98
98
  | `limit` | `number` | — | The first `limit` rows only — a **cap**, not pages; omit to read up to the server's row cap. |
99
99
  | `page` | `number` | — | Numbered pages of `page` rows: `page` (from 0), `setPage`, `pageCount`, `hasMore`, and `total`, counted by default. |
@@ -173,6 +173,12 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
173
173
  `loading && rows.length === 0`, never `loading` alone, or every re-key swaps the list for a
174
174
  spinner. **`isValidating`** is `true` whenever any request of the read is in flight — use it for
175
175
  a subtle refresh indicator.
176
+ - **`answeredAt`** moves each time the server answers again a read this screen already drew — a
177
+ realtime push, this app's own write re-read, a refocus, a feed grown — and never for this app's
178
+ write drawn over the rows. It is `0` while the rows shown predate the screen asking them: the
179
+ previous call's kept during a re-key, or the cache shown on a remount before its re-read lands.
180
+ To show what someone else changed, compare the rows a read drew with the rows it draws next only
181
+ where `answeredAt` moved and the call is the same.
176
182
  - **`error`** is a `string | null`. A failed query surfaces immediately — there is **no automatic
177
183
  retry** (no retry loop that masks the error). The last successful rows for the same key stay
178
184
  rendered. The next focus re-read or an explicit `refetch()` re-runs it.
@@ -232,7 +238,7 @@ Reads paginate two different ways, and the difference is load-bearing:
232
238
  The cursor is internal: the hook sends it and reads the next one back; you never see it. The
233
239
  server keysets a single sortable key (the runtime `sort`, else the template's own order on a
234
240
  column it projects, else the record id) and falls back to offset transparently for a multi-key
235
- sort it can't seek. So an infinite feed needs **no
241
+ sort, or a `first` key, it can't seek. So an infinite feed needs **no
236
242
  dedupe** — key rows by `__source_record_id` for stable React keys, not to guard against repeats.
237
243
  - **`page`, `limit`, `queryAll` and manual `rpc("query", { limit, offset })` use offset
238
244
  pagination** — count `offset` rows, skip them, return the next page. Two consequences a keyset
package/docs/files.md CHANGED
@@ -49,11 +49,16 @@ await submit({ ...fields, invoice_file_id: invoiceShot.id });
49
49
 
50
50
  Signature: `dist/hooks.d.ts`. Returns `{ upload, uploading, error }`:
51
51
 
52
- - `upload(file: File, options?: { fidelity }): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
52
+ - `upload(file: File, options?: { fidelity, place }): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
53
53
  (the file is never partially stored). `UploadedFile` is
54
54
  `{ id, filename, mime_type, url?, thumbnail_url? }` — `url`/`thumbnail_url` are presigned
55
55
  (24 h) and load directly in the sandboxed iframe, so a just-uploaded image previews without a
56
56
  round-trip.
57
+ - `place` — a photo's `{ latitude, longitude, accuracy }` (`PhotoPlace`): where the device stood as it took it, read
58
+ as the shot comes back (`requestGeofencedLocation`, `highAccuracy: true`) — the optimizer strips EXIF, so the photo
59
+ carries none of its own. Kept with the file, never echoed by the upload; refused on a file that is no image. A
60
+ person's whereabouts, so a read carries it only where the app declares `capabilities.photo_places` (`update_app`; a
61
+ JSON app stating `on_site` declares it), and only to a member of the app's organization.
57
62
  - `fidelity` — **how faithful the stored image must be**; the platform owns the pixels behind
58
63
  each step. `useAttachments().add` and `.attach` take it too.
59
64
 
@@ -228,10 +233,12 @@ file-shaped value anywhere in the result rows** at read time:
228
233
  - `size` (bytes), `created_at` (ISO upload timestamp) and `document_template_id` (the template a
229
234
  generated file was made from) — resolved from the file object at serving time, batch-loaded per
230
235
  response.
236
+ - `place` (where a photo's device stood, as its upload stated) — only where the app declares
237
+ `capabilities.photo_places`, and only to a member of the app's organization.
231
238
 
232
239
  A caller outside the app's organization receives only `id`, `filename`, `mime_type`, `url`,
233
240
  `thumbnail_url`, `preview_url`, `size`, `created_at` and `document_template_id` — never the storage
234
- address.
241
+ address, and never a place.
235
242
 
236
243
  Decode with `readFiles(cell)` → `AppFile[]` (`dist/row.d.ts`):
237
244
 
@@ -245,6 +252,7 @@ Decode with `readFiles(cell)` → `AppFile[]` (`dist/row.d.ts`):
245
252
  | `size` | `number?` | Byte size — **absent on older files not yet backfilled**; render only when present |
246
253
  | `created_at` | `string?` | ISO upload timestamp |
247
254
  | `document_template_id` | `string?` | The document template (`dtl_…`) the file was generated from — absent on a file a person uploaded, whatever its name |
255
+ | `place` | `PhotoPlace?` | `{ latitude, longitude, accuracy }` — where the device stood as it took the photo, as its upload stated (never proof); absent on a file uploaded with none, and on every read of an app not declaring `capabilities.photo_places` or by a caller outside its organization |
248
256
 
249
257
  `readFiles` skips entries the server didn't presign (no `url`), so you never render an unservable
250
258
  file. It is pure and never throws.
@@ -229,12 +229,14 @@ renders blank, fix the query projection — never widen the bindings.
229
229
 
230
230
  ## `useViewer()` — display-only identity
231
231
 
232
- `useViewer()` (`dist/viewer.d.ts`) → `{ memberId, loading }` — the signed-in member currently
233
- viewing the app. Under an admin's "View as" in the product it returns
232
+ `useViewer()` (`dist/viewer.d.ts`) → `{ memberId, groupIds, loading }` — the signed-in member currently
233
+ viewing the app, and the member groups they are in, by id. Under an admin's "View as" in the product it returns
234
234
  the **view-as target**. It is `null` for a standalone/public visitor, and until the context
235
- resolves — gate viewer-dependent UI on `loading`.
235
+ resolves — gate viewer-dependent UI on `loading`. `groupIds` is `null` where the host did not say (a
236
+ visitor from outside the organization, an older host).
236
237
 
237
- Use it to **personalize**: greet the member, default an assignment picker to them.
238
+ Use it to **personalize**: greet the member, default an assignment picker to them, lift the work
239
+ waiting on a group they are in. A query asks the same of the server (`current_member in_any_group`).
238
240
 
239
241
  **Never an authorization fact or a scoping mechanism** —
240
242
  [security](./security.md#useviewer-is-display-only).
@@ -250,6 +252,15 @@ states — `resolved` once it answered, `settled` once it answered OR failed, an
250
252
  `{ message, retry }`, `null` unless the read failed. Where it failed every fact is unknown, so a
251
253
  surface that dates or counts by them draws the failure rather than guessing the reader's zone.
252
254
 
255
+ `absences` lists who in the organization is away, or will be (`AppAbsence`; one that has ended is left
256
+ out): the member, the one standing in (`stand_in`), the first and last day (`starts_on`, `ends_on`,
257
+ `YYYY-MM-DD` in the workspace's calendar, both counting) and the groups of the member away
258
+ (`group_ids`). On those days a step the member owns — by a member field, or through a group — also
259
+ waits on the one standing in, so say it on the step ("thay Trần Thị Mai đến 08/10"). `null` where the
260
+ host did not say (a visitor from outside the organization, an older host). Both arrive with the
261
+ context, read once as the app opens. Display only: the workflow body re-checks who may act
262
+ (`current_member_stands_in_for(...)`).
263
+
253
264
  ## The workspace's zone at the root
254
265
 
255
266
  An app's root draws nothing until `useAppContext()` has `settled` — where it failed, it draws
@@ -357,6 +368,48 @@ table's id: read it off a row of the query that renders the badges (`rows[0]?.__
357
368
  rather than pasting a `tbl_` id, which a copy of the app cannot carry. Pass `undefined` while no
358
369
  row is on screen — no request is sent and `counts` is `{}`.
359
370
 
371
+ ## Record activity: `useRecordActivity`
372
+
373
+ Who changed which of a record's fields, and who commented on it — one entry per save
374
+ (`dist/activity.d.ts`). For a JSON app: `entity` is the alias the app's spec names the record's
375
+ entity under.
376
+
377
+ ```tsx
378
+ const { entries, next, loading, error, available, refetch } = useRecordActivity("job", recordId);
379
+ // The page past it, mounted where the reader asks for more:
380
+ const older = useRecordActivity("job", recordId, { after: next });
381
+ ```
382
+
383
+ - **Authority: the app's own read of the record.** The server reaches the record through the
384
+ entity's read of one row — the read the record screen draws it with — so a row that read does not
385
+ reach (the reader's scope, another workspace's) answers not found. Only the fields that read draws
386
+ appear: a field the app does not read never does, and an edit of one neither shows nor splits an
387
+ entry. A field taken out of the app while its table keeps it stays hidden; a field gone from its
388
+ table appears where the version serving when it was written read it, marked `removed`. Comments
389
+ appear only where the app declares `comments`.
390
+ - **Members of the app's organization only** — the trail names its people. Standalone, `available`
391
+ is `false` and nothing is read.
392
+ - **One entry per save.** Edits by one actor on one record less than ten minutes apart read as one
393
+ entry: each field from its value before the first edit to its value after the last, a field edited
394
+ back to where it began dropped (and an entry left with nothing). A comment, another actor or a
395
+ longer pause starts another entry. `at` is the entry's first write, `until` its last.
396
+ - **An entry** (`ActivityEntry`) is `kind: "change"` with `changes`, or `kind: "comment"` with
397
+ `comment: { id, body, mentions }`. `actor`: `{ kind, id, name, image? }` — `member` (a person),
398
+ `agent` (an AI acting for a member: the chat, an app's agent, an MCP client — named by that
399
+ member), `workflow` (a workflow on its own, named by the workflow) or `import` (a script over the
400
+ API or the CLI, named by the member whose key ran it). `name` is empty where nothing resolves it.
401
+ - **A change** (`ActivityChange`): `{ field_id, label, removed?, before, after }`. `field_id` is a
402
+ spec field's `id`; `label` the app's label today, else — a removed field — its label when written.
403
+ Values are drawn as the record's read draws them, so the cell readers apply: a select
404
+ `[{ key, label }]` (`readSelect`; each option's label as it was when written), a member field
405
+ `[{ id, name, image? }]`, a link `[{ id, display }]` (`readLinks`), files presigned (`readFiles`),
406
+ `null` for empty.
407
+ - **Pages**: newest first, about thirty entries each; `next` is where the trail goes on —
408
+ `undefined` at its end. A page may hold fewer entries and still say `next` where the trail it read
409
+ held little the reader may see.
410
+ - **Freshness:** read again when a push or this app's own write names the entity's reads, when a
411
+ comment is written or deleted through `useComments`, on focus, and on `refetch()`.
412
+
360
413
  ## Where each hook works
361
414
 
362
415
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
@@ -368,3 +421,4 @@ row is on screen — no request is sent and `counts` is `{}`.
368
421
  | `useViewer` | member id (view-as target) | `null` |
369
422
  | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
370
423
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
424
+ | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false` |
package/docs/queries.md CHANGED
@@ -847,15 +847,20 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
847
847
  text, options, people and what a row looks up finds what the reader reads. A number, a date or
848
848
  a rollup has no words; narrow it by range. Traversals / `locked` / `current_member` do not — bake those into the
849
849
  template.
850
- - **`sort`** — `[{ field_key, order, blank_position? }]` over output columns — the typed
851
- `QuerySortKey` the hooks accept. `blank_position` is `"top" | "bottom"`, defaulting to
852
- `"bottom"` — the only way to express the ascending reading of a **multi-key** sort over
853
- columns whose blankness IS the ranking (a pipeline's per-step date stamps).
850
+ - **`sort`** — keys over output columns, each breaking the ties of the ones before — the typed
851
+ `QuerySortKey` the hooks accept. `{ field_key, order, blank_position? }` orders by a column:
852
+ `blank_position` is `"top" | "bottom"`, defaulting to `"bottom"` — the only way to express the
853
+ ascending reading of a **multi-key** sort over columns whose blankness IS the ranking (a
854
+ pipeline's per-step date stamps). `{ first: [filter, …] }` (at most 8 filters, each bounded and
855
+ resolved as `filter` is) puts the rows each filter keeps, in turn, before the rest — a row stands
856
+ with the first it meets, the rows meeting none last — and the keys after it order each part: the
857
+ rows overdue, then due soon, each part by its due date. A caller's alone: a template's own `sort`
858
+ orders by its columns.
854
859
  - **`limit` / `offset` / keyset `cursor`** — pagination. `useQuery` with `limit` or `page` reads by
855
860
  `offset` (`limit` clamped to the 10,000-row cap, §10); `useQuery` with `more` opts into **keyset
856
861
  (seek)** by sending `keyset: true` + the prior page's `cursor`, and the server returns the next
857
862
  `next_cursor` (null on the last page). Seek stays O(page) and never skips/duplicates a row as the
858
- set shifts, falling back to offset for a multi-key sort it can't seek.
863
+ set shifts, falling back to offset for a multi-key sort, or a `first` key, it can't seek.
859
864
  - **`count: true`** — returns `{ total }` only: a COUNT over the *filtered* set, ignoring
860
865
  sort/limit/offset. Drives "Page 1 of N". With **`count_by: "<column>"`** the same scan also
861
866
  returns `counts` — rows per value of that projected column (a multi-value row under each value,
package/docs/runtime.md CHANGED
@@ -409,7 +409,8 @@ await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
409
409
 
410
410
  - An **empty `zones` array means "no geofence"** — resolves `ok` with the raw
411
411
  position; use it as a plain location read.
412
- - The fix is standard accuracy (high-accuracy mode is not requested) and never
412
+ - The fix is standard accuracy unless `opts.highAccuracy` asks for the device's best
413
+ (satellites where it has them — slower, for a place a record keeps), and never
413
414
  served from cache.
414
415
  - **Warning — the gate is advisory, client-side UX, not security.** It prevents
415
416
  honest-user mistakes; it cannot stop a tampered client, and the server never
package/docs/security.md CHANGED
@@ -10,6 +10,7 @@ Every data operation an app performs — queries, workflows, agent runs — exec
10
10
  | Workflows (`useWorkflow`) | App owner | Yes — `runtime.triggered_by_member_id` in the workflow body (`null` for anonymous) |
11
11
  | Agent runs (`useAgentRun`) | App owner | Yes — requires an authenticated member; runs are private to that member |
12
12
  | Comments (`useComments`) | App authority for **access**; the **author** is always the real member | Always — members-only, anonymous callers are rejected |
13
+ | Record activity (`useRecordActivity`) | The app's own read of the record — the entity's read of one row, and only the fields it draws | Always — the app's organization's members only |
13
14
 
14
15
  Consequences of owner authority:
15
16
 
@@ -87,6 +88,8 @@ returns `true` when the **triggering member** belongs to any of the listed group
87
88
 
88
89
  **Model approvers as a group**, not as a hardcoded member id or a client-side check: a group survives personnel changes, is auditable, and is the unit both predicates understand — `current_member_in_any_group([...])` gates the write while `current_member in_any_group [...]` in a query template gates the matching read, so non-approvers never see the pending queue *and* can't act on it.
89
90
 
91
+ **A member away names who stands in.** While their absence holds, `current_member_stands_in_for([record["fld_approver"]])` is `true` for the one they named, and `current_member_stands_in_for_any_group(["grp_abc123"])` for the one standing in for a member of the group — OR either beside the owner's own check where the step should not stall while its owner is away. Same fail-closed rules; the server resolves both per press.
92
+
90
93
  ## Public apps: anonymous reach and its bounds
91
94
 
92
95
  A publicly-shared app (its own origin, or its public link) is reachable by **anyone** — the public share grants `app:use` to anonymous visitors and to authenticated members of any other org alike. What anonymous visitors can and cannot do:
@@ -99,6 +102,7 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
99
102
  | Query/workflow file outputs | Yes — file cells and workflow-produced files return direct presigned URLs (24-hour TTL) that anonymous viewers can fetch; see [files](./files.md) |
100
103
  | Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
101
104
  | Comments (`useComments`) | **No** — members-only |
105
+ | Record activity (`useRecordActivity`) | **No** — the app's organization's members only |
102
106
  | Member roster (`useMembers`) | **No** — same-org members only (below) |
103
107
 
104
108
  **Warning — `is_current_member` on a public app resolves to the app owner.** An anonymous request has no viewer to bind, so both `is_current_member` and `current_member in_any_group` fall back to the owner's identity and the owner's groups. A "my items" query shows every anonymous visitor the *owner's* items, and a manager-only read template gated on a group the owner belongs to **passes for every anonymous visitor**. Never rely on viewer-bound predicates in a public app.
package/docs/workflows.md CHANGED
@@ -512,7 +512,7 @@ full menu by category, so a miss is one informed retry.
512
512
  | **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `formatNumber(value, decimals)` (fixed-decimal, ungrouped), `formatDecimal(value, decimals, locale)` (grouped for a reader — `formatDecimal(151000, 0, "vi-VN")` → `151.000`), `numberToWords(x, lang?)` (`"vi"` or `"en"`) |
513
513
  | **Object** | `keys`, `values`, `entries`, `get`, `pick`, `omit`, `merge`, `nonNullKeys` |
514
514
  | **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
515
- | **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])` |
515
+ | **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])`, `current_member_stands_in_for([memberId])`, `current_member_stands_in_for_any_group(["grp_…"])` |
516
516
 
517
517
  **Emptiness on a record cell.** `isNull` is the strict check — true only for `null`/`undefined`.
518
518
  A cleared cell does not always reach a workflow that way: the platform stores a cleared date or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.115.3",
3
+ "version": "0.116.0",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {