@colixsystems/widget-sdk 0.204.0 → 0.205.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/README.md +9 -1
- package/dist/contract.cjs +95 -1
- package/dist/contract.js +95 -1
- package/dist/hooks.js +2 -1
- package/dist/index.d.ts +81 -0
- package/dist/index.js +5 -0
- package/dist/index.native.js +5 -0
- package/dist/linter.cjs +6 -2
- package/dist/linter.js +6 -2
- package/dist/stats-hooks.js +144 -0
- package/package.json +1 -1
- package/src/contract.js +95 -1
package/README.md
CHANGED
|
@@ -83,7 +83,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
83
83
|
|
|
84
84
|
## Status
|
|
85
85
|
|
|
86
|
-
`v0.
|
|
86
|
+
`v0.205.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**.
|
|
87
|
+
|
|
88
|
+
### What's new in 0.205.0 (contract 1.164.0)
|
|
89
|
+
|
|
90
|
+
**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.
|
|
91
|
+
|
|
92
|
+
**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`).
|
|
93
|
+
|
|
94
|
+
**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
95
|
|
|
88
96
|
### What's new in 0.204.0 (contract 1.163.0)
|
|
89
97
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|
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.
|
package/dist/index.native.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.205.0",
|
|
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
|
-
|
|
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,
|