@colixsystems/widget-sdk 0.204.0 → 0.205.1

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/README.md CHANGED
@@ -83,7 +83,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
83
83
 
84
84
  ## Status
85
85
 
86
- `v0.204.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
86
+ `v0.205.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
87
+
88
+ ### What's new in 0.205.1
89
+
90
+ **An encrypted column never labels a relation.** A RELATION read's `label` now comes from the first non-encrypted STRING/TEXT column on the target table when no display column is set, and an encrypted display column gives no label (`null`): a label is read whatever the reader may read of the target row, so it never carries encrypted text. To show an encrypted value of a related row, read that row through the table it lives in. A documentation correction; `CONTRACT.version` is unchanged.
91
+
92
+ ### What's new in 0.205.0 (contract 1.164.0)
93
+
94
+ **An app's user totals for its own admins — `useUserStats({ days, group_id })`.** Active and deactivated users, new users per day (7 / 30 days) or week (90 days) with the previous period's total beside them, users in no group, empty groups, and invite figures. Totals only — never a list of people. Needs `users.read:*` and the viewer's admin user access (else `error.code === "FORBIDDEN"`); `stats.invites` is null unless the widget also declares `users.write:*` and the viewer holds it.
95
+
96
+ **User activity — `useUserActivity({ days, group_id })` and `useInactiveUsers({ inactive_days, group_id, limit, offset })`.** For a workspace that records user activity: who is online now, active in the last day / week / month, never active, active users per day or week, the web / iOS / Android split and the active share per group — and a page of the users not seen recently, never-seen first. Needs `users.activity:*` and the viewer's "View user activity" permission (else `error.code === "FORBIDDEN"`); when the workspace does not record activity, `error.reason === "ACTIVITY_DISABLED"` — show that, not zeros. Before user activity is offered to the workspace at all, `error.reason` is `"ACTIVITY_UNAVAILABLE"`: hide the section, as for `FORBIDDEN`. `useInactiveUsers` also returns `remind(userId, { title, body })`, which notifies one of them and records when (each row carries `last_reminded_at`). Days follow the workspace's time zone (`period.time_zone`).
97
+
98
+ **Linter fix.** A widget that uses `useGroups` or `useUsers` is no longer refused for a write it never makes: `StyleSheet.create(...)` and an event subscription's bare `remove()` (an `AppState` listener, say) are not user or group writes.
87
99
 
88
100
  ### What's new in 0.204.0 (contract 1.163.0)
89
101
 
@@ -2102,7 +2114,7 @@ useEffect(() => {
2102
2114
 
2103
2115
  ### What's new in 0.42.0
2104
2116
 
2105
- **RELATION columns hydrate with a display label (sc-1181).** Record reads now return `{ id, label }` for ONE_TO_ONE / ONE_TO_MANY and `[{ id, label }, ...]` for MANY_TO_MANY (empty array when no links) — `label` is the value of the column pointed at by the new optional `display_column_id` on `DatastoreSchemaColumn`, or, when unset, the first STRING/TEXT column on the target table. Widgets should render `record.<rel>.label` (or `record.<rel>.map(r => r.label).join(", ")` for M:M) directly; `.id` is still there for the foreign-key case. The cell-formatting helpers in the built-in `DataList` and `DataValue` widgets already walk arrays and prefer `label` over `name` / `id` — author widgets that need the same can copy that pattern. `CONTRACT.version` is unchanged.
2117
+ **RELATION columns hydrate with a display label (sc-1181).** Record reads now return `{ id, label }` for ONE_TO_ONE / ONE_TO_MANY and `[{ id, label }, ...]` for MANY_TO_MANY (empty array when no links) — `label` is the value of the column pointed at by the new optional `display_column_id` on `DatastoreSchemaColumn`, or, when unset, the first STRING/TEXT column on the target table (since 0.205.1: the first non-encrypted one; an encrypted display column gives no label). Widgets should render `record.<rel>.label` (or `record.<rel>.map(r => r.label).join(", ")` for M:M) directly; `.id` is still there for the foreign-key case. The cell-formatting helpers in the built-in `DataList` and `DataValue` widgets already walk arrays and prefer `label` over `name` / `id` — author widgets that need the same can copy that pattern. `CONTRACT.version` is unchanged.
2106
2118
 
2107
2119
  ### What's new in 0.41.0
2108
2120
 
package/dist/contract.cjs CHANGED
@@ -1975,6 +1975,95 @@ const HOOKS = [
1975
1975
  requiredContextSlice: ["directory.users"],
1976
1976
  scopes: ["users.read:*"],
1977
1977
  },
1978
+ // sc-10691 / REQ-USERMGMT-10 — an app's user totals for its own admins.
1979
+ {
1980
+ name: "useUserStats",
1981
+ signature: "useUserStats({ days, group_id })",
1982
+ optionalArgs: ["days", "group_id"],
1983
+ description:
1984
+ "Workspace user TOTALS for an app's own administrators — never a list " +
1985
+ "of people. `days` is 7 or 30 (charted per day) or 90 (per ISO week), in " +
1986
+ "the workspace's time zone (`stats.period.time_zone`); " +
1987
+ "`group_id` (a useGroups() row id) narrows the user and invite " +
1988
+ "figures to that group. `stats.users` = { active, deactivated, " +
1989
+ "without_group }; `stats.new_users` = { total, previous_total, buckets: " +
1990
+ "[{ date, count }] } with every bucket present and previous_total the " +
1991
+ "equal-length window before the period; `stats.groups` and " +
1992
+ "`stats.empty_groups`; `stats.invites` = { " +
1993
+ "pending, expiring_soon, expired, accepted }. `without_group`, `groups` " +
1994
+ "and `empty_groups` are null on a group-scoped read. `invites` is null unless " +
1995
+ "the widget also declares users.write:* AND the viewer holds users.write " +
1996
+ "— render \"no access\" for that section rather than zeros. A viewer " +
1997
+ "without the admin user view gets error.code FORBIDDEN; show an " +
1998
+ "explicit no-access state.",
1999
+ returnShape: {
2000
+ stats:
2001
+ "{ period, group_id, users, new_users, groups, empty_groups, invites } | null",
2002
+ loading: "boolean",
2003
+ fetching: "boolean",
2004
+ error: "DirectoryError | null // FORBIDDEN without the admin user view, NOT_FOUND for a foreign group",
2005
+ refetch: "() => Promise<void>",
2006
+ },
2007
+ requiredContextSlice: ["directory.users"],
2008
+ scopes: ["users.read:*"],
2009
+ },
2010
+ // sc-10691 / REQ-USERMGMT-12 — user activity, behind users.activity.
2011
+ {
2012
+ name: "useUserActivity",
2013
+ signature: "useUserActivity({ days, group_id })",
2014
+ optionalArgs: ["days", "group_id"],
2015
+ description:
2016
+ "User ACTIVITY for an app's administrators, for a workspace that " +
2017
+ "records it: `activity.online_now` (app open in the last two minutes), " +
2018
+ "`activity.active` = { day (since midnight), week, month (last 7/30 " +
2019
+ "days), period (distinct users active in the period) }, " +
2020
+ "`activity.never_active`, " +
2021
+ "`activity.daily_active` = [{ date, count }] distinct active users per " +
2022
+ "day (7/30 days) or week (90) in the workspace's time zone, `activity.platforms` = { web, ios, " +
2023
+ "android, unknown } users active in the period, `activity.by_group` = " +
2024
+ "[{ group_id, name, users, active }] (null on a group-scoped read), " +
2025
+ "`activity.users` and `activity.recording_since` (activity before it " +
2026
+ "is unknown — say \"since <date>\" next to never-active figures). Needs " +
2027
+ "users.activity:* AND the viewer's \"View user activity\" permission " +
2028
+ "(else error.code FORBIDDEN). error.reason \"ACTIVITY_UNAVAILABLE\" means " +
2029
+ "user activity is not offered to this workspace yet: hide the section, " +
2030
+ "like FORBIDDEN. When the workspace does not record activity, " +
2031
+ "error.reason is \"ACTIVITY_DISABLED\": show that state, not zeros. " +
2032
+ "Pass null instead of a query to read nothing (a section switched off).",
2033
+ returnShape: {
2034
+ activity:
2035
+ "{ period, group_id, recording_since, users, online_now, active, never_active, daily_active, platforms, by_group } | null",
2036
+ loading: "boolean",
2037
+ fetching: "boolean",
2038
+ error: "DirectoryError | null // FORBIDDEN; reason ACTIVITY_UNAVAILABLE (hide) or ACTIVITY_DISABLED (not recorded)",
2039
+ refetch: "() => Promise<void>",
2040
+ },
2041
+ requiredContextSlice: ["directory.users"],
2042
+ scopes: ["users.activity:*"],
2043
+ },
2044
+ {
2045
+ name: "useInactiveUsers",
2046
+ signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
2047
+ optionalArgs: ["inactive_days", "group_id", "limit", "offset"],
2048
+ description:
2049
+ "One page of users not seen for `inactive_days` (1-365, default 30), " +
2050
+ "never-seen first, then least recently seen: `users` = [{ id, name, " +
2051
+ "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2052
+ "since recording began), `total` for paging. `remind(id, { title, body })` " +
2053
+ "notifies one of them, records when (last_reminded_at) and re-reads. " +
2054
+ "Same gate and errors as useUserActivity; null reads nothing.",
2055
+ returnShape: {
2056
+ users: "Array<{ id, name, last_active_at, last_reminded_at }>",
2057
+ total: "number | null",
2058
+ loading: "boolean",
2059
+ fetching: "boolean",
2060
+ error: "DirectoryError | null",
2061
+ refetch: "() => Promise<void>",
2062
+ remind: "(id, { title, body }) => Promise<void>",
2063
+ },
2064
+ requiredContextSlice: ["directory.users"],
2065
+ scopes: ["users.activity:*"],
2066
+ },
1978
2067
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
1979
2068
  // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
1980
2069
  // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
@@ -5370,7 +5459,12 @@ const CONTRACT = deepFreeze({
5370
5459
  // `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
5371
5460
  // component scope (colour, radius). A scope may now name `sdkComponents`
5372
5461
  // and bind no widget field. Both stop under reduced motion.
5373
- version: "1.163.0",
5462
+ // 1.164.0: additive (sc-10691) — `useUserStats(query)` reads an app's user
5463
+ // totals through the new `ctx.directory.users.stats`; `useUserActivity`
5464
+ // and `useInactiveUsers` read user activity through the new
5465
+ // `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
5466
+ // new `users.activity:*` scope.
5467
+ version: "1.164.0",
5374
5468
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5375
5469
  hooks: HOOKS,
5376
5470
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1975,6 +1975,95 @@ const HOOKS = [
1975
1975
  requiredContextSlice: ["directory.users"],
1976
1976
  scopes: ["users.read:*"],
1977
1977
  },
1978
+ // sc-10691 / REQ-USERMGMT-10 — an app's user totals for its own admins.
1979
+ {
1980
+ name: "useUserStats",
1981
+ signature: "useUserStats({ days, group_id })",
1982
+ optionalArgs: ["days", "group_id"],
1983
+ description:
1984
+ "Workspace user TOTALS for an app's own administrators — never a list " +
1985
+ "of people. `days` is 7 or 30 (charted per day) or 90 (per ISO week), in " +
1986
+ "the workspace's time zone (`stats.period.time_zone`); " +
1987
+ "`group_id` (a useGroups() row id) narrows the user and invite " +
1988
+ "figures to that group. `stats.users` = { active, deactivated, " +
1989
+ "without_group }; `stats.new_users` = { total, previous_total, buckets: " +
1990
+ "[{ date, count }] } with every bucket present and previous_total the " +
1991
+ "equal-length window before the period; `stats.groups` and " +
1992
+ "`stats.empty_groups`; `stats.invites` = { " +
1993
+ "pending, expiring_soon, expired, accepted }. `without_group`, `groups` " +
1994
+ "and `empty_groups` are null on a group-scoped read. `invites` is null unless " +
1995
+ "the widget also declares users.write:* AND the viewer holds users.write " +
1996
+ "— render \"no access\" for that section rather than zeros. A viewer " +
1997
+ "without the admin user view gets error.code FORBIDDEN; show an " +
1998
+ "explicit no-access state.",
1999
+ returnShape: {
2000
+ stats:
2001
+ "{ period, group_id, users, new_users, groups, empty_groups, invites } | null",
2002
+ loading: "boolean",
2003
+ fetching: "boolean",
2004
+ error: "DirectoryError | null // FORBIDDEN without the admin user view, NOT_FOUND for a foreign group",
2005
+ refetch: "() => Promise<void>",
2006
+ },
2007
+ requiredContextSlice: ["directory.users"],
2008
+ scopes: ["users.read:*"],
2009
+ },
2010
+ // sc-10691 / REQ-USERMGMT-12 — user activity, behind users.activity.
2011
+ {
2012
+ name: "useUserActivity",
2013
+ signature: "useUserActivity({ days, group_id })",
2014
+ optionalArgs: ["days", "group_id"],
2015
+ description:
2016
+ "User ACTIVITY for an app's administrators, for a workspace that " +
2017
+ "records it: `activity.online_now` (app open in the last two minutes), " +
2018
+ "`activity.active` = { day (since midnight), week, month (last 7/30 " +
2019
+ "days), period (distinct users active in the period) }, " +
2020
+ "`activity.never_active`, " +
2021
+ "`activity.daily_active` = [{ date, count }] distinct active users per " +
2022
+ "day (7/30 days) or week (90) in the workspace's time zone, `activity.platforms` = { web, ios, " +
2023
+ "android, unknown } users active in the period, `activity.by_group` = " +
2024
+ "[{ group_id, name, users, active }] (null on a group-scoped read), " +
2025
+ "`activity.users` and `activity.recording_since` (activity before it " +
2026
+ "is unknown — say \"since <date>\" next to never-active figures). Needs " +
2027
+ "users.activity:* AND the viewer's \"View user activity\" permission " +
2028
+ "(else error.code FORBIDDEN). error.reason \"ACTIVITY_UNAVAILABLE\" means " +
2029
+ "user activity is not offered to this workspace yet: hide the section, " +
2030
+ "like FORBIDDEN. When the workspace does not record activity, " +
2031
+ "error.reason is \"ACTIVITY_DISABLED\": show that state, not zeros. " +
2032
+ "Pass null instead of a query to read nothing (a section switched off).",
2033
+ returnShape: {
2034
+ activity:
2035
+ "{ period, group_id, recording_since, users, online_now, active, never_active, daily_active, platforms, by_group } | null",
2036
+ loading: "boolean",
2037
+ fetching: "boolean",
2038
+ error: "DirectoryError | null // FORBIDDEN; reason ACTIVITY_UNAVAILABLE (hide) or ACTIVITY_DISABLED (not recorded)",
2039
+ refetch: "() => Promise<void>",
2040
+ },
2041
+ requiredContextSlice: ["directory.users"],
2042
+ scopes: ["users.activity:*"],
2043
+ },
2044
+ {
2045
+ name: "useInactiveUsers",
2046
+ signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
2047
+ optionalArgs: ["inactive_days", "group_id", "limit", "offset"],
2048
+ description:
2049
+ "One page of users not seen for `inactive_days` (1-365, default 30), " +
2050
+ "never-seen first, then least recently seen: `users` = [{ id, name, " +
2051
+ "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2052
+ "since recording began), `total` for paging. `remind(id, { title, body })` " +
2053
+ "notifies one of them, records when (last_reminded_at) and re-reads. " +
2054
+ "Same gate and errors as useUserActivity; null reads nothing.",
2055
+ returnShape: {
2056
+ users: "Array<{ id, name, last_active_at, last_reminded_at }>",
2057
+ total: "number | null",
2058
+ loading: "boolean",
2059
+ fetching: "boolean",
2060
+ error: "DirectoryError | null",
2061
+ refetch: "() => Promise<void>",
2062
+ remind: "(id, { title, body }) => Promise<void>",
2063
+ },
2064
+ requiredContextSlice: ["directory.users"],
2065
+ scopes: ["users.activity:*"],
2066
+ },
1978
2067
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
1979
2068
  // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
1980
2069
  // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
@@ -5366,7 +5455,12 @@ const CONTRACT = deepFreeze({
5366
5455
  // `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
5367
5456
  // component scope (colour, radius). A scope may now name `sdkComponents`
5368
5457
  // and bind no widget field. Both stop under reduced motion.
5369
- version: "1.163.0",
5458
+ // 1.164.0: additive (sc-10691) — `useUserStats(query)` reads an app's user
5459
+ // totals through the new `ctx.directory.users.stats`; `useUserActivity`
5460
+ // and `useInactiveUsers` read user activity through the new
5461
+ // `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
5462
+ // new `users.activity:*` scope.
5463
+ version: "1.164.0",
5370
5464
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5371
5465
  hooks: HOOKS,
5372
5466
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -5613,7 +5613,8 @@ export class DirectoryError extends Error {
5613
5613
  }
5614
5614
  }
5615
5615
 
5616
- function toDirectoryError(err) {
5616
+ // Internal: shared with stats-hooks.js, not re-exported by the index.
5617
+ export function toDirectoryError(err) {
5617
5618
  // See the note in toDatastoreError: the directory CLIENT has a same-named
5618
5619
  // class, so a client rejection is read from the envelope below, not here.
5619
5620
  if (err instanceof DirectoryError) return err;
package/dist/index.d.ts CHANGED
@@ -1037,6 +1037,87 @@ export function useDatastoreFigure(
1037
1037
  options?: FigureOptions,
1038
1038
  ): FigureResult;
1039
1039
 
1040
+ export interface UserStatsQuery {
1041
+ days?: 7 | 30 | 90;
1042
+ /** A `useGroups()` row id: narrows user and invite figures to that group. */
1043
+ group_id?: string;
1044
+ }
1045
+
1046
+ export interface UserStats {
1047
+ /** Days follow the workspace's `time_zone` (IANA; "UTC" when unset). */
1048
+ period: { days: 7 | 30 | 90; bucket: "day" | "week"; from: string; to: string; time_zone: string };
1049
+ group_id: string | null;
1050
+ users: { active: number; deactivated: number; without_group: number | null };
1051
+ /** `previous_total`: the equal-length window just before the period. */
1052
+ new_users: {
1053
+ total: number;
1054
+ previous_total: number;
1055
+ buckets: Array<{ date: string; count: number }>;
1056
+ };
1057
+ /** The workspace's groups; null on a group-scoped read. */
1058
+ groups: number | null;
1059
+ empty_groups: number | null;
1060
+ /** Null unless the viewer may list invites (`users.write`). */
1061
+ invites: { pending: number; expiring_soon: number; expired: number; accepted: number } | null;
1062
+ }
1063
+
1064
+ /** An app's user totals for its own admins — never a list of people. */
1065
+ export function useUserStats(query?: UserStatsQuery): {
1066
+ stats: UserStats | null;
1067
+ loading: boolean;
1068
+ fetching: boolean;
1069
+ error: Error | null;
1070
+ refetch(): Promise<void>;
1071
+ };
1072
+
1073
+ export interface UserActivity {
1074
+ /** Days follow the workspace's `time_zone` (IANA; "UTC" when unset). */
1075
+ period: { days: 7 | 30 | 90; bucket: "day" | "week"; from: string; to: string; time_zone: string };
1076
+ group_id: string | null;
1077
+ /** When the workspace started recording. */
1078
+ recording_since: string | null;
1079
+ users: number;
1080
+ /** Seen with the app open in the last two minutes. */
1081
+ online_now: number;
1082
+ /** `day`: since midnight; `week`/`month`: last 7/30 days; `period`: distinct over the period. */
1083
+ active: { day: number; week: number; month: number; period: number };
1084
+ never_active: number;
1085
+ daily_active: Array<{ date: string; count: number }>;
1086
+ platforms: { web: number; ios: number; android: number; unknown: number };
1087
+ by_group: Array<{ group_id: string; name: string; users: number; active: number }> | null;
1088
+ }
1089
+
1090
+ /**
1091
+ * User activity for an app's admins. Needs `users.activity:*` and the
1092
+ * viewer's "View user activity" permission; `error.reason` is
1093
+ * "ACTIVITY_DISABLED" when the workspace does not record activity, and
1094
+ * "ACTIVITY_UNAVAILABLE" (hide the section) before it is offered at all.
1095
+ */
1096
+ export function useUserActivity(query?: { days?: 7 | 30 | 90; group_id?: string }): {
1097
+ activity: UserActivity | null;
1098
+ loading: boolean;
1099
+ fetching: boolean;
1100
+ error: Error | null;
1101
+ refetch(): Promise<void>;
1102
+ };
1103
+
1104
+ /** Users not seen for `inactive_days` (default 30), never-seen first. */
1105
+ export function useInactiveUsers(query?: {
1106
+ inactive_days?: number;
1107
+ group_id?: string;
1108
+ limit?: number;
1109
+ offset?: number;
1110
+ } | null): {
1111
+ users: Array<{ id: string; name: string; last_active_at: string | null; last_reminded_at: string | null }>;
1112
+ total: number | null;
1113
+ loading: boolean;
1114
+ fetching: boolean;
1115
+ error: Error | null;
1116
+ refetch(): Promise<void>;
1117
+ /** Notify one user, record when, then re-read the page. */
1118
+ remind(id: string, message: { title: string; body: string }): Promise<void>;
1119
+ };
1120
+
1040
1121
  export function useDatastoreMutation<T = unknown>(
1041
1122
  table: string,
1042
1123
  ): MutationApi<T>;
@@ -1335,8 +1416,9 @@ export interface DatastoreSchemaColumn {
1335
1416
  /**
1336
1417
  * For RELATION columns only — id of a column on `target_table_id` whose
1337
1418
  * value supplies the `label` hydrated next to the related record id in
1338
- * record responses. `null` falls back to the first STRING/TEXT column on
1339
- * the target table.
1419
+ * record responses. `null` falls back to the first non-encrypted
1420
+ * STRING/TEXT column on the target table; an encrypted display column
1421
+ * gives no label (`null`).
1340
1422
  */
1341
1423
  display_column_id?: string | null;
1342
1424
  /** True when this column is the table's display/identification column. */
package/dist/index.js CHANGED
@@ -124,6 +124,11 @@ export {
124
124
  AudioPlayerError,
125
125
  } from "./audio.js";
126
126
  export { useDatastoreFigure } from "./datastore-figure.js";
127
+ export {
128
+ useUserStats,
129
+ useUserActivity,
130
+ useInactiveUsers,
131
+ } from "./stats-hooks.js";
127
132
  // sc-6607 — `<Overlay>` is one shared component (./overlay-view.js) bound to
128
133
  // each platform's primitives, so a widget's modal escapes its container box
129
134
  // identically on the Player and the Expo export.
@@ -122,6 +122,11 @@ export {
122
122
  AudioPlayerError,
123
123
  } from "./audio.js";
124
124
  export { useDatastoreFigure } from "./datastore-figure.js";
125
+ export {
126
+ useUserStats,
127
+ useUserActivity,
128
+ useInactiveUsers,
129
+ } from "./stats-hooks.js";
125
130
  // sc-6607 — see the note on the web mirror in ./index.js.
126
131
  export { Overlay } from "./overlay.native.js";
127
132
  // sc-8104 — see the note on the web mirror in ./index.js.
package/dist/linter.cjs CHANGED
@@ -771,7 +771,8 @@ function _scopeRules(source, manifest) {
771
771
  // the edit-style write scope above.
772
772
  if (usesUsersHook && !firedUsersDelete) {
773
773
  for (const m of USER_DELETE_METHODS) {
774
- const re = new RegExp(`\\.${m}\\s*\\(`);
774
+ // remove(userId) takes an id; an event subscription's remove() does not.
775
+ const re = new RegExp(`\\.${m}\\s*\\(\\s*[^)\\s]`);
775
776
  if (re.test(line)) {
776
777
  if (
777
778
  !declared.has("users.delete:*") &&
@@ -791,7 +792,10 @@ function _scopeRules(source, manifest) {
791
792
  }
792
793
  if (usesGroupsHook && !firedGroupsWrite) {
793
794
  for (const m of GROUP_MUTATION_METHODS) {
794
- const re = new RegExp(`\\.${m}\\s*\\(`);
795
+ // Not group writes: `StyleSheet.create(`, and a subscription's bare remove().
796
+ const lookbehind = m === "create" ? "(?<!StyleSheet)" : "";
797
+ const needsArgs = m === "remove" ? "\\s*[^)\\s]" : "";
798
+ const re = new RegExp(`${lookbehind}\\.${m}\\s*\\(${needsArgs}`);
795
799
  if (re.test(line)) {
796
800
  if (m === "remove" && usesUsersHook) continue;
797
801
  if (
package/dist/linter.js CHANGED
@@ -723,7 +723,8 @@ function _scopeRules(source, manifest) {
723
723
  // the edit-style write scope above.
724
724
  if (usesUsersHook && !firedUsersDelete) {
725
725
  for (const m of USER_DELETE_METHODS) {
726
- const re = new RegExp(`\\.${m}\\s*\\(`);
726
+ // remove(userId) takes an id; an event subscription's remove() does not.
727
+ const re = new RegExp(`\\.${m}\\s*\\(\\s*[^)\\s]`);
727
728
  if (re.test(line)) {
728
729
  if (
729
730
  !declared.has("users.delete:*") &&
@@ -743,7 +744,10 @@ function _scopeRules(source, manifest) {
743
744
  }
744
745
  if (usesGroupsHook && !firedGroupsWrite) {
745
746
  for (const m of GROUP_MUTATION_METHODS) {
746
- const re = new RegExp(`\\.${m}\\s*\\(`);
747
+ // Not group writes: `StyleSheet.create(`, and a subscription's bare remove().
748
+ const lookbehind = m === "create" ? "(?<!StyleSheet)" : "";
749
+ const needsArgs = m === "remove" ? "\\s*[^)\\s]" : "";
750
+ const re = new RegExp(`${lookbehind}\\.${m}\\s*\\(${needsArgs}`);
747
751
  if (re.test(line)) {
748
752
  // `.remove(` is also a useUsers method; only blame groups
749
753
  // when the source also uses the groups hook AND the source
@@ -0,0 +1,144 @@
1
+ // sc-10691 — read-only hooks for an app's user totals and user activity. Each
2
+ // is one keyed read, stale-while-revalidate like useDatastoreQuery, and each
3
+ // re-runs on the page's pull-to-refresh.
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import { toDirectoryError, useRefresh, useWidgetContextOrThrow } from "./hooks.js";
6
+
7
+ const EMPTY = { identity: null, key: null, data: null, error: null };
8
+
9
+ function serialize(value) {
10
+ try {
11
+ return JSON.stringify(value === undefined ? null : value);
12
+ } catch (_e) {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Runs `read()` whenever `key` changes. A changed key on the same `identity`
19
+ * keeps the previous data on screen (`fetching`); a new identity starts over
20
+ * (`loading`). A null key is "nothing to read" and settles empty.
21
+ */
22
+ function useKeyedRead(read, identity, key, mapError) {
23
+ const readRef = useRef(read);
24
+ readRef.current = read;
25
+ const runRef = useRef(0);
26
+ const [settled, setSettled] = useState(EMPTY);
27
+ const [fetching, setFetching] = useState(key !== null);
28
+
29
+ const run = useCallback(async () => {
30
+ const myRun = ++runRef.current;
31
+ if (key === null) {
32
+ setSettled(EMPTY);
33
+ setFetching(false);
34
+ return;
35
+ }
36
+ setFetching(true);
37
+ try {
38
+ const data = await readRef.current();
39
+ if (runRef.current === myRun) setSettled({ identity, key, data, error: null });
40
+ } catch (err) {
41
+ if (runRef.current !== myRun) return;
42
+ setSettled((prev) => ({
43
+ identity,
44
+ key,
45
+ data: prev.identity === identity ? prev.data : null,
46
+ error: mapError(err),
47
+ }));
48
+ } finally {
49
+ if (runRef.current === myRun) setFetching(false);
50
+ }
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [identity, key]);
53
+
54
+ useEffect(() => {
55
+ run();
56
+ }, [run]);
57
+ useRefresh(run);
58
+
59
+ const sameIdentity = settled.identity === identity;
60
+ return {
61
+ data: sameIdentity ? settled.data : null,
62
+ loading: key !== null && !sameIdentity,
63
+ fetching,
64
+ error: sameIdentity ? settled.error : null,
65
+ refetch: run,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * The app's user totals for its own admins — never a list of people.
71
+ * `query`: `{ days?: 7 | 30 | 90, group_id? }`. Returns `{ stats, loading,
72
+ * fetching, error, refetch }`; `stats.invites` is null unless the viewer may
73
+ * list invites. A viewer without the admin user view gets a FORBIDDEN error.
74
+ */
75
+ export function useUserStats(query) {
76
+ const ctx = useWidgetContextOrThrow("useUserStats");
77
+ if (!ctx.directory || !ctx.directory.users || typeof ctx.directory.users.stats !== "function") {
78
+ throw new Error("useUserStats: host did not inject a directory client (ctx.directory.users.stats)");
79
+ }
80
+ const users = ctx.directory.users;
81
+ const key = serialize(query);
82
+ const { data, ...state } = useKeyedRead(() => users.stats(query), "stats", key, toDirectoryError);
83
+ return { ...state, stats: data };
84
+ }
85
+
86
+ function useUsersNamespace(hookName, method) {
87
+ const ctx = useWidgetContextOrThrow(hookName);
88
+ const users = ctx.directory && ctx.directory.users;
89
+ if (!users || typeof users[method] !== "function") {
90
+ throw new Error(`${hookName}: host did not inject a directory client (ctx.directory.users.${method})`);
91
+ }
92
+ return users;
93
+ }
94
+
95
+ /**
96
+ * sc-10691 — user activity for an app's admins: who is online now, active
97
+ * in the last day / week / month, never active, active users per bucket,
98
+ * the platform split and the active share per group. `query`: `{ days?: 7 |
99
+ * 30 | 90, group_id? }`. `error.reason` is "ACTIVITY_DISABLED" when the
100
+ * workspace does not record activity, "ACTIVITY_UNAVAILABLE" before it is
101
+ * offered to the workspace, and `error.code` FORBIDDEN without the
102
+ * users.activity permission. Pass `null` to read nothing.
103
+ */
104
+ export function useUserActivity(query) {
105
+ const users = useUsersNamespace("useUserActivity", "activity");
106
+ const key = query === null ? null : serialize(query);
107
+ const { data, ...state } = useKeyedRead(() => users.activity(query), "activity", key, toDirectoryError);
108
+ return { ...state, activity: data };
109
+ }
110
+
111
+ /**
112
+ * sc-10691 — one page of users not seen for `inactive_days` (default 30),
113
+ * never-seen first. `query`: `{ inactive_days?, group_id?, limit?, offset? }`.
114
+ * Returns `{ users, total, loading, fetching, error, refetch, remind }`; each
115
+ * user is `{ id, name, last_active_at, last_reminded_at }` (last_active_at
116
+ * null when never seen). `remind(id, { title, body })` notifies one and
117
+ * re-reads the page. Same gate as useUserActivity.
118
+ */
119
+ export function useInactiveUsers(query) {
120
+ const users = useUsersNamespace("useInactiveUsers", "inactive");
121
+ const key = query === null ? null : serialize(query);
122
+ const { data, ...state } = useKeyedRead(() => users.inactive(query), "inactive", key, toDirectoryError);
123
+ const { refetch } = state;
124
+ const remind = useCallback(
125
+ async (id, message) => {
126
+ if (typeof users.remind !== "function") {
127
+ throw new Error("useInactiveUsers: host did not inject ctx.directory.users.remind");
128
+ }
129
+ try {
130
+ await users.remind(id, message);
131
+ } catch (err) {
132
+ throw toDirectoryError(err);
133
+ }
134
+ await refetch();
135
+ },
136
+ [users, refetch],
137
+ );
138
+ return {
139
+ ...state,
140
+ remind,
141
+ users: data && Array.isArray(data.data) ? data.data : [],
142
+ total: data && data.meta && Number.isFinite(data.meta.total) ? data.meta.total : null,
143
+ };
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.204.0",
3
+ "version": "0.205.1",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1975,6 +1975,95 @@ const HOOKS = [
1975
1975
  requiredContextSlice: ["directory.users"],
1976
1976
  scopes: ["users.read:*"],
1977
1977
  },
1978
+ // sc-10691 / REQ-USERMGMT-10 — an app's user totals for its own admins.
1979
+ {
1980
+ name: "useUserStats",
1981
+ signature: "useUserStats({ days, group_id })",
1982
+ optionalArgs: ["days", "group_id"],
1983
+ description:
1984
+ "Workspace user TOTALS for an app's own administrators — never a list " +
1985
+ "of people. `days` is 7 or 30 (charted per day) or 90 (per ISO week), in " +
1986
+ "the workspace's time zone (`stats.period.time_zone`); " +
1987
+ "`group_id` (a useGroups() row id) narrows the user and invite " +
1988
+ "figures to that group. `stats.users` = { active, deactivated, " +
1989
+ "without_group }; `stats.new_users` = { total, previous_total, buckets: " +
1990
+ "[{ date, count }] } with every bucket present and previous_total the " +
1991
+ "equal-length window before the period; `stats.groups` and " +
1992
+ "`stats.empty_groups`; `stats.invites` = { " +
1993
+ "pending, expiring_soon, expired, accepted }. `without_group`, `groups` " +
1994
+ "and `empty_groups` are null on a group-scoped read. `invites` is null unless " +
1995
+ "the widget also declares users.write:* AND the viewer holds users.write " +
1996
+ "— render \"no access\" for that section rather than zeros. A viewer " +
1997
+ "without the admin user view gets error.code FORBIDDEN; show an " +
1998
+ "explicit no-access state.",
1999
+ returnShape: {
2000
+ stats:
2001
+ "{ period, group_id, users, new_users, groups, empty_groups, invites } | null",
2002
+ loading: "boolean",
2003
+ fetching: "boolean",
2004
+ error: "DirectoryError | null // FORBIDDEN without the admin user view, NOT_FOUND for a foreign group",
2005
+ refetch: "() => Promise<void>",
2006
+ },
2007
+ requiredContextSlice: ["directory.users"],
2008
+ scopes: ["users.read:*"],
2009
+ },
2010
+ // sc-10691 / REQ-USERMGMT-12 — user activity, behind users.activity.
2011
+ {
2012
+ name: "useUserActivity",
2013
+ signature: "useUserActivity({ days, group_id })",
2014
+ optionalArgs: ["days", "group_id"],
2015
+ description:
2016
+ "User ACTIVITY for an app's administrators, for a workspace that " +
2017
+ "records it: `activity.online_now` (app open in the last two minutes), " +
2018
+ "`activity.active` = { day (since midnight), week, month (last 7/30 " +
2019
+ "days), period (distinct users active in the period) }, " +
2020
+ "`activity.never_active`, " +
2021
+ "`activity.daily_active` = [{ date, count }] distinct active users per " +
2022
+ "day (7/30 days) or week (90) in the workspace's time zone, `activity.platforms` = { web, ios, " +
2023
+ "android, unknown } users active in the period, `activity.by_group` = " +
2024
+ "[{ group_id, name, users, active }] (null on a group-scoped read), " +
2025
+ "`activity.users` and `activity.recording_since` (activity before it " +
2026
+ "is unknown — say \"since <date>\" next to never-active figures). Needs " +
2027
+ "users.activity:* AND the viewer's \"View user activity\" permission " +
2028
+ "(else error.code FORBIDDEN). error.reason \"ACTIVITY_UNAVAILABLE\" means " +
2029
+ "user activity is not offered to this workspace yet: hide the section, " +
2030
+ "like FORBIDDEN. When the workspace does not record activity, " +
2031
+ "error.reason is \"ACTIVITY_DISABLED\": show that state, not zeros. " +
2032
+ "Pass null instead of a query to read nothing (a section switched off).",
2033
+ returnShape: {
2034
+ activity:
2035
+ "{ period, group_id, recording_since, users, online_now, active, never_active, daily_active, platforms, by_group } | null",
2036
+ loading: "boolean",
2037
+ fetching: "boolean",
2038
+ error: "DirectoryError | null // FORBIDDEN; reason ACTIVITY_UNAVAILABLE (hide) or ACTIVITY_DISABLED (not recorded)",
2039
+ refetch: "() => Promise<void>",
2040
+ },
2041
+ requiredContextSlice: ["directory.users"],
2042
+ scopes: ["users.activity:*"],
2043
+ },
2044
+ {
2045
+ name: "useInactiveUsers",
2046
+ signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
2047
+ optionalArgs: ["inactive_days", "group_id", "limit", "offset"],
2048
+ description:
2049
+ "One page of users not seen for `inactive_days` (1-365, default 30), " +
2050
+ "never-seen first, then least recently seen: `users` = [{ id, name, " +
2051
+ "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2052
+ "since recording began), `total` for paging. `remind(id, { title, body })` " +
2053
+ "notifies one of them, records when (last_reminded_at) and re-reads. " +
2054
+ "Same gate and errors as useUserActivity; null reads nothing.",
2055
+ returnShape: {
2056
+ users: "Array<{ id, name, last_active_at, last_reminded_at }>",
2057
+ total: "number | null",
2058
+ loading: "boolean",
2059
+ fetching: "boolean",
2060
+ error: "DirectoryError | null",
2061
+ refetch: "() => Promise<void>",
2062
+ remind: "(id, { title, body }) => Promise<void>",
2063
+ },
2064
+ requiredContextSlice: ["directory.users"],
2065
+ scopes: ["users.activity:*"],
2066
+ },
1978
2067
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
1979
2068
  // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
1980
2069
  // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
@@ -5366,7 +5455,12 @@ const CONTRACT = deepFreeze({
5366
5455
  // `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
5367
5456
  // component scope (colour, radius). A scope may now name `sdkComponents`
5368
5457
  // and bind no widget field. Both stop under reduced motion.
5369
- version: "1.163.0",
5458
+ // 1.164.0: additive (sc-10691) — `useUserStats(query)` reads an app's user
5459
+ // totals through the new `ctx.directory.users.stats`; `useUserActivity`
5460
+ // and `useInactiveUsers` read user activity through the new
5461
+ // `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
5462
+ // new `users.activity:*` scope.
5463
+ version: "1.164.0",
5370
5464
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5371
5465
  hooks: HOOKS,
5372
5466
  primitives: PRIMITIVES,