@flow-industries/id 0.19.4 → 0.21.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/dist/sdk/client/actions.d.ts +20 -0
- package/dist/sdk/client/actions.js +68 -0
- package/dist/sdk/client/index.d.ts +4 -1
- package/dist/sdk/client/index.js +3 -0
- package/dist/sdk/client/json-api.d.ts +25 -0
- package/dist/sdk/client/json-api.js +51 -0
- package/dist/sdk/client/study.d.ts +17 -0
- package/dist/sdk/client/study.js +55 -0
- package/dist/sdk/driver-error.d.ts +3 -0
- package/dist/sdk/driver-error.js +6 -0
- package/dist/sdk/react/active-action.d.ts +14 -0
- package/dist/sdk/react/active-action.js +124 -0
- package/dist/sdk/react/index.d.ts +2 -1
- package/dist/sdk/react/index.js +1 -0
- package/dist/sdk/types/actions.d.ts +108 -0
- package/dist/sdk/types/actions.js +4 -0
- package/dist/sdk/types/index.d.ts +3 -1
- package/dist/sdk/types/rooms.d.ts +11 -0
- package/dist/sdk/types/study.d.ts +154 -0
- package/dist/sdk/types/study.js +1 -0
- package/dist/sdk/types/xp.d.ts +105 -4
- package/dist/sdk/xp/curve.d.ts +29 -0
- package/dist/sdk/xp/curve.js +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ActionsApi, ActiveSessionConflict, StartActionResult } from "../types";
|
|
2
|
+
/** Thrown on any non-2xx actions response other than a start's 409;
|
|
3
|
+
* `reason` carries the route's machine-readable code (`rate_limited`,
|
|
4
|
+
* `not_found`, `subject_required`, ...). */
|
|
5
|
+
export declare class ActionsRequestError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly reason?: string | undefined;
|
|
8
|
+
constructor(status: number, message: string, reason?: string | undefined);
|
|
9
|
+
}
|
|
10
|
+
/** Whether a start collided with a running session instead of opening one. */
|
|
11
|
+
export declare function isActiveSessionConflict(result: StartActionResult): result is ActiveSessionConflict;
|
|
12
|
+
/**
|
|
13
|
+
* Thin typed client for the caller's own action sessions, bound to a host and
|
|
14
|
+
* the Flow token supplier — the `createRoomsApi` pattern. The catalog is
|
|
15
|
+
* public; everything under `/me` attaches the caller's bearer and fails fast
|
|
16
|
+
* with a 401-shaped error when no session exists. A start that collides with
|
|
17
|
+
* a running session resolves to the conflict rather than throwing, because a
|
|
18
|
+
* "switch?" prompt is the ordinary next step, not a fault.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createActionsApi(host: string, getToken: () => Promise<string | null>): ActionsApi;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createJsonApi } from "./json-api";
|
|
3
|
+
/** Thrown on any non-2xx actions response other than a start's 409;
|
|
4
|
+
* `reason` carries the route's machine-readable code (`rate_limited`,
|
|
5
|
+
* `not_found`, `subject_required`, ...). */
|
|
6
|
+
export class ActionsRequestError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
reason;
|
|
9
|
+
constructor(status, message, reason) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
this.name = "ActionsRequestError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const conflictSchema = z.object({
|
|
17
|
+
error: z.literal("active_session"),
|
|
18
|
+
active: z.object({ sessionId: z.string() }).loose(),
|
|
19
|
+
});
|
|
20
|
+
/** Whether a start collided with a running session instead of opening one. */
|
|
21
|
+
export function isActiveSessionConflict(result) {
|
|
22
|
+
return "error" in result && result.error === "active_session";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Thin typed client for the caller's own action sessions, bound to a host and
|
|
26
|
+
* the Flow token supplier — the `createRoomsApi` pattern. The catalog is
|
|
27
|
+
* public; everything under `/me` attaches the caller's bearer and fails fast
|
|
28
|
+
* with a 401-shaped error when no session exists. A start that collides with
|
|
29
|
+
* a running session resolves to the conflict rather than throwing, because a
|
|
30
|
+
* "switch?" prompt is the ordinary next step, not a fault.
|
|
31
|
+
*/
|
|
32
|
+
export function createActionsApi(host, getToken) {
|
|
33
|
+
const api = createJsonApi(host, "/api/actions", getToken, (status, message, reason) => new ActionsRequestError(status, message, reason));
|
|
34
|
+
return {
|
|
35
|
+
catalog: () => api.request("/catalog"),
|
|
36
|
+
me: () => api.request("/me", { authRequired: true }),
|
|
37
|
+
async start(input) {
|
|
38
|
+
const body = {
|
|
39
|
+
...input,
|
|
40
|
+
sessionId: input.sessionId ?? crypto.randomUUID(),
|
|
41
|
+
};
|
|
42
|
+
const result = await api.exchange("/me/start", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body,
|
|
45
|
+
authRequired: true,
|
|
46
|
+
});
|
|
47
|
+
if (result.status === 409 &&
|
|
48
|
+
conflictSchema.safeParse(result.payload).success) {
|
|
49
|
+
/* SAFETY: the schema just confirmed the 409 body is the active_session conflict. */
|
|
50
|
+
return result.payload;
|
|
51
|
+
}
|
|
52
|
+
if (!result.ok)
|
|
53
|
+
throw api.failure(result);
|
|
54
|
+
/* SAFETY: a 2xx from /me/start is the { session } envelope. */
|
|
55
|
+
return result.payload;
|
|
56
|
+
},
|
|
57
|
+
event: (request) => api.request("/me/event", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: request,
|
|
60
|
+
authRequired: true,
|
|
61
|
+
}),
|
|
62
|
+
finish: (request) => api.request("/me/finish", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
body: request,
|
|
65
|
+
authRequired: true,
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
-
export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
2
|
+
export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
|
|
3
|
+
export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
|
|
4
|
+
export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
|
|
3
5
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
4
6
|
export { createDialogHost } from "./dialog-host";
|
|
5
7
|
export { createFlowWidget } from "./flow-widget";
|
|
@@ -8,3 +10,4 @@ export { closeProfile, openProfile } from "./open-profile";
|
|
|
8
10
|
export { createProfileButton } from "./profile-button";
|
|
9
11
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
10
12
|
export { createStaticFlow } from "./static-flow";
|
|
13
|
+
export { createStudyApi, StudyRequestError } from "./study";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
+
export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
|
|
3
|
+
export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
|
|
2
4
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
3
5
|
export { createDialogHost } from "./dialog-host";
|
|
4
6
|
export { createFlowWidget } from "./flow-widget";
|
|
@@ -7,3 +9,4 @@ export { closeProfile, openProfile } from "./open-profile";
|
|
|
7
9
|
export { createProfileButton } from "./profile-button";
|
|
8
10
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
9
11
|
export { createStaticFlow } from "./static-flow";
|
|
12
|
+
export { createStudyApi, StudyRequestError } from "./study";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type JsonRequestOptions = {
|
|
2
|
+
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
|
3
|
+
body?: unknown;
|
|
4
|
+
/** Fail fast with a 401-shaped error when the caller has no token, instead
|
|
5
|
+
* of spending a request the server is going to refuse. */
|
|
6
|
+
authRequired?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type JsonExchange = {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
status: number;
|
|
11
|
+
/** The decoded JSON body, or `{}` when there was none to decode. */
|
|
12
|
+
payload: unknown;
|
|
13
|
+
};
|
|
14
|
+
export type JsonApiError = (status: number, message: string, reason?: string) => Error;
|
|
15
|
+
/**
|
|
16
|
+
* The transport under the typed clients: one host, one route prefix, the Flow
|
|
17
|
+
* token supplier, and the error class a client throws. `exchange` returns the
|
|
18
|
+
* raw status and payload for the few responses a client treats as values (a
|
|
19
|
+
* start's 409); `request` is the common path that throws on any non-2xx.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError): {
|
|
22
|
+
exchange: (path: string, options?: JsonRequestOptions) => Promise<JsonExchange>;
|
|
23
|
+
request: <T>(path: string, options?: JsonRequestOptions) => Promise<T>;
|
|
24
|
+
failure: (exchange: JsonExchange) => Error;
|
|
25
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Every first-party JSON route answers a failure as `{ error, reason? }`;
|
|
3
|
+
* for the actions and study routes `error` is itself the machine code. */
|
|
4
|
+
const failureSchema = z.object({
|
|
5
|
+
error: z.string().optional(),
|
|
6
|
+
reason: z.string().optional(),
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* The transport under the typed clients: one host, one route prefix, the Flow
|
|
10
|
+
* token supplier, and the error class a client throws. `exchange` returns the
|
|
11
|
+
* raw status and payload for the few responses a client treats as values (a
|
|
12
|
+
* start's 409); `request` is the common path that throws on any non-2xx.
|
|
13
|
+
*/
|
|
14
|
+
export function createJsonApi(host, prefix, getToken, makeError) {
|
|
15
|
+
/** The error a non-2xx exchange throws: the route's own wording when it
|
|
16
|
+
* sent one, and its machine code as `reason`. */
|
|
17
|
+
function failure(exchange) {
|
|
18
|
+
const parsed = failureSchema.safeParse(exchange.payload);
|
|
19
|
+
const { error, reason } = parsed.success ? parsed.data : {};
|
|
20
|
+
return makeError(exchange.status, error ?? `Request failed (${exchange.status})`, reason ?? error);
|
|
21
|
+
}
|
|
22
|
+
async function exchange(path, options = {}) {
|
|
23
|
+
const headers = {};
|
|
24
|
+
const token = await getToken();
|
|
25
|
+
if (token) {
|
|
26
|
+
headers.Authorization = `Bearer ${token}`;
|
|
27
|
+
}
|
|
28
|
+
else if (options.authRequired) {
|
|
29
|
+
throw makeError(401, "Sign in first");
|
|
30
|
+
}
|
|
31
|
+
if (options.body !== undefined) {
|
|
32
|
+
headers["Content-Type"] = "application/json";
|
|
33
|
+
}
|
|
34
|
+
const res = await fetch(`${host}${prefix}${path}`, {
|
|
35
|
+
method: options.method ?? "GET",
|
|
36
|
+
headers,
|
|
37
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
38
|
+
});
|
|
39
|
+
const payload = await res.json().catch(() => ({}));
|
|
40
|
+
return { ok: res.ok, status: res.status, payload };
|
|
41
|
+
}
|
|
42
|
+
async function request(path, options = {}) {
|
|
43
|
+
const result = await exchange(path, options);
|
|
44
|
+
if (!result.ok)
|
|
45
|
+
throw failure(result);
|
|
46
|
+
/* SAFETY: each caller names the response type of the route it just called; a
|
|
47
|
+
non-ok status has already thrown above with the server's own wording. */
|
|
48
|
+
return result.payload;
|
|
49
|
+
}
|
|
50
|
+
return { exchange, request, failure };
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StudyApi } from "../types";
|
|
2
|
+
/** Thrown on any non-2xx study response; `reason` carries the route's
|
|
3
|
+
* machine-readable code (`name_taken`, `subject_limit`, `field_unknown`,
|
|
4
|
+
* `not_found`, ...). */
|
|
5
|
+
export declare class StudyRequestError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly reason?: string | undefined;
|
|
8
|
+
constructor(status: number, message: string, reason?: string | undefined);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Thin typed client for the study catalog, the caller's study subjects and
|
|
12
|
+
* their finished study time, bound to a host and the Flow token supplier. The
|
|
13
|
+
* catalog is public and long-cached by the browser; every other route attaches
|
|
14
|
+
* the caller's bearer and fails fast with a 401-shaped error when no session
|
|
15
|
+
* exists.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createStudyApi(host: string, getToken: () => Promise<string | null>): StudyApi;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createJsonApi } from "./json-api";
|
|
2
|
+
/** Thrown on any non-2xx study response; `reason` carries the route's
|
|
3
|
+
* machine-readable code (`name_taken`, `subject_limit`, `field_unknown`,
|
|
4
|
+
* `not_found`, ...). */
|
|
5
|
+
export class StudyRequestError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
reason;
|
|
8
|
+
constructor(status, message, reason) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
this.name = "StudyRequestError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Thin typed client for the study catalog, the caller's study subjects and
|
|
17
|
+
* their finished study time, bound to a host and the Flow token supplier. The
|
|
18
|
+
* catalog is public and long-cached by the browser; every other route attaches
|
|
19
|
+
* the caller's bearer and fails fast with a 401-shaped error when no session
|
|
20
|
+
* exists.
|
|
21
|
+
*/
|
|
22
|
+
export function createStudyApi(host, getToken) {
|
|
23
|
+
const api = createJsonApi(host, "/api/study", getToken, (status, message, reason) => new StudyRequestError(status, message, reason));
|
|
24
|
+
const subjectPath = (id) => `/subjects/${encodeURIComponent(id)}`;
|
|
25
|
+
return {
|
|
26
|
+
catalog: () => api.request("/catalog"),
|
|
27
|
+
subjects: (options) => api.request(options?.archived ? "/subjects?archived=1" : "/subjects", { authRequired: true }),
|
|
28
|
+
createSubject: (input) => api.request("/subjects", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: input,
|
|
31
|
+
authRequired: true,
|
|
32
|
+
}),
|
|
33
|
+
updateSubject: (id, patch) => api.request(subjectPath(id), {
|
|
34
|
+
method: "PATCH",
|
|
35
|
+
body: patch,
|
|
36
|
+
authRequired: true,
|
|
37
|
+
}),
|
|
38
|
+
deleteSubject: (id) => api.request(subjectPath(id), {
|
|
39
|
+
method: "DELETE",
|
|
40
|
+
authRequired: true,
|
|
41
|
+
}),
|
|
42
|
+
summary: (range) => api.request(`/summary?${new URLSearchParams({ range: range ?? "today" })}`, { authRequired: true }),
|
|
43
|
+
sessions: (options) => {
|
|
44
|
+
const params = new URLSearchParams({ subject: options.subject });
|
|
45
|
+
if (options.before !== undefined)
|
|
46
|
+
params.set("before", options.before);
|
|
47
|
+
if (options.limit !== undefined) {
|
|
48
|
+
params.set("limit", String(options.limit));
|
|
49
|
+
}
|
|
50
|
+
return api.request(`/sessions?${params}`, {
|
|
51
|
+
authRequired: true,
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -6,3 +6,6 @@
|
|
|
6
6
|
/** Reads a named string field off a driver error. */
|
|
7
7
|
export declare function errField(cause: unknown, key: string): string;
|
|
8
8
|
export declare function errCause(cause: unknown): object | undefined;
|
|
9
|
+
/** Whether a driver error is Postgres's unique_violation (23505), read off the
|
|
10
|
+
* error itself or the cause a query wrapper nests it under. */
|
|
11
|
+
export declare function isUniqueViolation(cause: unknown): boolean;
|
package/dist/sdk/driver-error.js
CHANGED
|
@@ -15,3 +15,9 @@ export function errCause(cause) {
|
|
|
15
15
|
const value = Object.entries(Object(cause)).find(([k]) => k === "cause")?.[1];
|
|
16
16
|
return isObject(value) ? value : undefined;
|
|
17
17
|
}
|
|
18
|
+
/** Whether a driver error is Postgres's unique_violation (23505), read off the
|
|
19
|
+
* error itself or the cause a query wrapper nests it under. */
|
|
20
|
+
export function isUniqueViolation(cause) {
|
|
21
|
+
return (errField(cause, "code") === "23505" ||
|
|
22
|
+
errField(errCause(cause), "code") === "23505");
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActiveActionState, UseActiveActionOptions } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* The caller's running action session, kept current without polling: it is
|
|
4
|
+
* read from `GET /api/actions/me` on mount, whenever the tab becomes visible
|
|
5
|
+
* again, after this hook's own `start`/`event`/`finish`, and on `refresh()`.
|
|
6
|
+
* Between reads the clock runs locally from the server's `accruedSeconds`, so
|
|
7
|
+
* a reload mid-session recovers the right time and a paused session stands
|
|
8
|
+
* still. Signed-out callers read as idle rather than as an error.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* const { active, elapsedSeconds, start, finish } = useActiveAction();
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare function useActiveAction(options?: UseActiveActionOptions): ActiveActionState;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "../client/actions";
|
|
3
|
+
import { useFlow } from "./hooks";
|
|
4
|
+
function asError(cause) {
|
|
5
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
6
|
+
}
|
|
7
|
+
function elapsedFromAnchor(anchor, now) {
|
|
8
|
+
if (anchor.paused)
|
|
9
|
+
return anchor.accrued;
|
|
10
|
+
return anchor.accrued + (now - anchor.at) / 1000;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The caller's running action session, kept current without polling: it is
|
|
14
|
+
* read from `GET /api/actions/me` on mount, whenever the tab becomes visible
|
|
15
|
+
* again, after this hook's own `start`/`event`/`finish`, and on `refresh()`.
|
|
16
|
+
* Between reads the clock runs locally from the server's `accruedSeconds`, so
|
|
17
|
+
* a reload mid-session recovers the right time and a paused session stands
|
|
18
|
+
* still. Signed-out callers read as idle rather than as an error.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const { active, elapsedSeconds, start, finish } = useActiveAction();
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function useActiveAction(options = {}) {
|
|
25
|
+
const flow = useFlow();
|
|
26
|
+
const host = options.host ?? flow.host;
|
|
27
|
+
const api = useMemo(() => createActionsApi(host, flow.getToken), [host, flow]);
|
|
28
|
+
const [active, setActive] = useState(null);
|
|
29
|
+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
30
|
+
const [loaded, setLoaded] = useState(false);
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const anchor = useRef(null);
|
|
33
|
+
const activeId = useRef(null);
|
|
34
|
+
// Bumped by every adoption, so a read that was in flight when a start or
|
|
35
|
+
// finish answered cannot land afterwards and resurrect the older state.
|
|
36
|
+
const generation = useRef(0);
|
|
37
|
+
const adopt = useCallback((next) => {
|
|
38
|
+
generation.current += 1;
|
|
39
|
+
anchor.current = next
|
|
40
|
+
? { accrued: next.accruedSeconds, at: Date.now(), paused: next.paused }
|
|
41
|
+
: null;
|
|
42
|
+
activeId.current = next?.sessionId ?? null;
|
|
43
|
+
setActive(next);
|
|
44
|
+
setElapsedSeconds(next?.accruedSeconds ?? 0);
|
|
45
|
+
}, []);
|
|
46
|
+
const refresh = useCallback(async () => {
|
|
47
|
+
const ticket = generation.current;
|
|
48
|
+
try {
|
|
49
|
+
const next = await api.me();
|
|
50
|
+
if (ticket !== generation.current)
|
|
51
|
+
return;
|
|
52
|
+
adopt(next);
|
|
53
|
+
setError(null);
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
if (ticket !== generation.current)
|
|
57
|
+
return;
|
|
58
|
+
if (cause instanceof ActionsRequestError && cause.status === 401) {
|
|
59
|
+
adopt(null);
|
|
60
|
+
setError(null);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
setError(asError(cause));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
setLoaded(true);
|
|
68
|
+
}
|
|
69
|
+
}, [api, adopt]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
void refresh();
|
|
72
|
+
const onVisibility = () => {
|
|
73
|
+
if (document.visibilityState === "visible")
|
|
74
|
+
void refresh();
|
|
75
|
+
};
|
|
76
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
77
|
+
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
78
|
+
}, [refresh]);
|
|
79
|
+
// A render clock, not a poll: one local tick per second while the session's
|
|
80
|
+
// clock runs, and nothing at all while it is paused or idle.
|
|
81
|
+
const running = active !== null && !active.paused;
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!running)
|
|
84
|
+
return;
|
|
85
|
+
const tick = () => {
|
|
86
|
+
const a = anchor.current;
|
|
87
|
+
if (a)
|
|
88
|
+
setElapsedSeconds(elapsedFromAnchor(a, Date.now()));
|
|
89
|
+
};
|
|
90
|
+
const timer = setInterval(tick, 1000);
|
|
91
|
+
return () => clearInterval(timer);
|
|
92
|
+
}, [running]);
|
|
93
|
+
const start = useCallback(async (input) => {
|
|
94
|
+
const result = await api.start(input);
|
|
95
|
+
if (!isActiveSessionConflict(result))
|
|
96
|
+
adopt(result.session);
|
|
97
|
+
return result;
|
|
98
|
+
}, [api, adopt]);
|
|
99
|
+
const event = useCallback(async (request) => {
|
|
100
|
+
const response = await api.event(request);
|
|
101
|
+
// Whether the clock stopped is the server's derivation from the event
|
|
102
|
+
// log, so a pause or resume re-reads rather than guessing.
|
|
103
|
+
if (request.kind === "pause" || request.kind === "resume") {
|
|
104
|
+
await refresh();
|
|
105
|
+
}
|
|
106
|
+
return response;
|
|
107
|
+
}, [api, refresh]);
|
|
108
|
+
const finish = useCallback(async (request) => {
|
|
109
|
+
const response = await api.finish(request);
|
|
110
|
+
if (activeId.current === request.sessionId)
|
|
111
|
+
adopt(null);
|
|
112
|
+
return response;
|
|
113
|
+
}, [api, adopt]);
|
|
114
|
+
return {
|
|
115
|
+
active,
|
|
116
|
+
elapsedSeconds,
|
|
117
|
+
loading: !loaded,
|
|
118
|
+
error,
|
|
119
|
+
refresh,
|
|
120
|
+
start,
|
|
121
|
+
event,
|
|
122
|
+
finish,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseOpenProfileOptions, } from "../types";
|
|
1
|
+
export type { ActiveActionState, FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseActiveActionOptions, UseOpenProfileOptions, } from "../types";
|
|
2
|
+
export { useActiveAction } from "./active-action";
|
|
2
3
|
export { FlowWidget } from "./flow-widget";
|
|
3
4
|
export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
|
|
4
5
|
export { useOpenProfile } from "./open-profile";
|
package/dist/sdk/react/index.js
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** The SDK-side contract for actions and study: what `createActionsApi`,
|
|
2
|
+
* `createStudyApi` and `useActiveAction` take and return. The wire shapes
|
|
3
|
+
* themselves live in `./xp` and `./study`; this file only names the client
|
|
4
|
+
* inputs and the composed results a consumer app programs against. */
|
|
5
|
+
import type { CreateStudySubjectInput, StudyCatalog, StudyRange, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRemovalResponse, StudySummary, UpdateStudySubjectInput } from "./study";
|
|
6
|
+
import type { ActionCatalogEntry, ActionCatalogResponse, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, FinishActionResponse, StartActionResponse, UserActionSurface } from "./xp";
|
|
7
|
+
/** The curve parameters `estimateActionXp` reads. A catalog entry satisfies
|
|
8
|
+
* it, and so does the server's own registry descriptor, so the widget and a
|
|
9
|
+
* consumer app estimate from the same function without a conversion. */
|
|
10
|
+
export type ActionCurve = Pick<ActionCatalogEntry, "mode" | "base" | "tauMinutes" | "dailyCapXp" | "minDurationSeconds" | "flowMultiplier">;
|
|
11
|
+
/** What a live estimate is computed from: the running session's elapsed time
|
|
12
|
+
* plus the day context `GET /api/actions/me` carries. */
|
|
13
|
+
export interface XpEstimateContext {
|
|
14
|
+
elapsedSeconds: number;
|
|
15
|
+
/** Minutes already credited to this source today, so a day-cumulative curve
|
|
16
|
+
* starts from the right point. */
|
|
17
|
+
priorMinutes: number;
|
|
18
|
+
flowScore: number;
|
|
19
|
+
}
|
|
20
|
+
/** Body of POST /api/actions/me/start as the SDK takes it. `sessionId` is the
|
|
21
|
+
* client-minted idempotency key for the whole session; the client mints a UUID
|
|
22
|
+
* when it is omitted. */
|
|
23
|
+
export interface StartActionInput {
|
|
24
|
+
source: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
subjectId?: string;
|
|
27
|
+
/** Recorded as `metadata.surface`; defaults to `web` on the server. */
|
|
28
|
+
surface?: UserActionSurface;
|
|
29
|
+
/** Finish and grant the running session before starting this one. */
|
|
30
|
+
replace?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** A start either opened (or replayed) a session or collided with a running
|
|
33
|
+
* one. The 409 is a value, not an error: the UI's next step is a "switch?"
|
|
34
|
+
* prompt, not an error state. */
|
|
35
|
+
export type StartActionResult = StartActionResponse | ActiveSessionConflict;
|
|
36
|
+
/** Body of POST /api/actions/me/event. */
|
|
37
|
+
export interface ActionEventRequest {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
kind: ActionTransitionKind;
|
|
40
|
+
}
|
|
41
|
+
/** Response of POST /api/actions/me/event. `recorded` is false when the
|
|
42
|
+
* transition was a no-op (pausing a paused session, a second focus_lost). */
|
|
43
|
+
export interface ActionEventResponse {
|
|
44
|
+
ok: true;
|
|
45
|
+
recorded: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Body of POST /api/actions/me/finish. */
|
|
48
|
+
export interface FinishActionRequest {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
}
|
|
51
|
+
/** Typed client for the caller's own action sessions and the public catalog. */
|
|
52
|
+
export interface ActionsApi {
|
|
53
|
+
catalog(): Promise<ActionCatalogResponse>;
|
|
54
|
+
/** The caller's running session, or null when idle. */
|
|
55
|
+
me(): Promise<ActiveActionResponse | null>;
|
|
56
|
+
start(input: StartActionInput): Promise<StartActionResult>;
|
|
57
|
+
event(request: ActionEventRequest): Promise<ActionEventResponse>;
|
|
58
|
+
finish(request: FinishActionRequest): Promise<FinishActionResponse>;
|
|
59
|
+
}
|
|
60
|
+
export interface ListStudySubjectsOptions {
|
|
61
|
+
/** Include archived subjects after the live ones. */
|
|
62
|
+
archived?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** Query of GET /api/study/sessions as the SDK takes it: one subject's
|
|
65
|
+
* finished sessions, newest first. Feed a page's `nextBefore` back as
|
|
66
|
+
* `before` until it is null. */
|
|
67
|
+
export interface ListStudySessionsOptions {
|
|
68
|
+
/** The subject's id; an archived subject is readable too. */
|
|
69
|
+
subject: string;
|
|
70
|
+
/** ISO instant; only sessions started strictly before it are listed. */
|
|
71
|
+
before?: string;
|
|
72
|
+
/** 1..100; the server defaults to 50. */
|
|
73
|
+
limit?: number;
|
|
74
|
+
}
|
|
75
|
+
/** Envelope of every single-subject study route. */
|
|
76
|
+
export interface StudySubjectResponse {
|
|
77
|
+
subject: StudySubject;
|
|
78
|
+
}
|
|
79
|
+
/** Typed client for the study catalog and the caller's study subjects. */
|
|
80
|
+
export interface StudyApi {
|
|
81
|
+
catalog(): Promise<StudyCatalog>;
|
|
82
|
+
subjects(options?: ListStudySubjectsOptions): Promise<StudySubjectList>;
|
|
83
|
+
createSubject(input: CreateStudySubjectInput): Promise<StudySubjectResponse>;
|
|
84
|
+
updateSubject(id: string, patch: UpdateStudySubjectInput): Promise<StudySubjectResponse>;
|
|
85
|
+
deleteSubject(id: string): Promise<StudySubjectRemovalResponse>;
|
|
86
|
+
/** Finished study time per subject and category; `range` defaults to
|
|
87
|
+
* `today`. The running session is absent: add `/api/actions/me` live. */
|
|
88
|
+
summary(range?: StudyRange): Promise<StudySummary>;
|
|
89
|
+
sessions(options: ListStudySessionsOptions): Promise<StudySessionPage>;
|
|
90
|
+
}
|
|
91
|
+
export type UseActiveActionOptions = {
|
|
92
|
+
/** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
|
|
93
|
+
host?: string;
|
|
94
|
+
};
|
|
95
|
+
/** What `useActiveAction` returns. `elapsedSeconds` is extrapolated locally
|
|
96
|
+
* from the server's `accruedSeconds` and stands still while `paused`. */
|
|
97
|
+
export interface ActiveActionState {
|
|
98
|
+
active: ActiveActionResponse | null;
|
|
99
|
+
elapsedSeconds: number;
|
|
100
|
+
/** True until the first read of `/api/actions/me` has answered. */
|
|
101
|
+
loading: boolean;
|
|
102
|
+
error: Error | null;
|
|
103
|
+
/** Re-reads the running session from the server. */
|
|
104
|
+
refresh: () => Promise<void>;
|
|
105
|
+
start: (input: StartActionInput) => Promise<StartActionResult>;
|
|
106
|
+
event: (request: ActionEventRequest) => Promise<ActionEventResponse>;
|
|
107
|
+
finish: (request: FinishActionRequest) => Promise<FinishActionResponse>;
|
|
108
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The SDK-side contract for actions and study: what `createActionsApi`,
|
|
2
|
+
* `createStudyApi` and `useActiveAction` take and return. The wire shapes
|
|
3
|
+
* themselves live in `./xp` and `./study`; this file only names the client
|
|
4
|
+
* inputs and the composed results a consumer app programs against. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type { ActionCurve, ActionEventRequest, ActionEventResponse, ActionsApi, ActiveActionState, FinishActionRequest, ListStudySessionsOptions, ListStudySubjectsOptions, StartActionInput, StartActionResult, StudyApi, StudySubjectResponse, UseActiveActionOptions, XpEstimateContext, } from "./actions";
|
|
1
2
|
export type { AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
|
|
2
3
|
export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
|
|
3
4
|
export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
|
|
@@ -12,5 +13,6 @@ export type { CreateRoomEventInput, RoomEventArchive, RoomEventAttendance, RoomE
|
|
|
12
13
|
export type { CreateRoomInput, GlobalRole, ModerationSubject, MyRooms, PlayerPositionReport, RoleChangeVerdict, RoomCapabilities, RoomCapability, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPlayerPosition, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, StaffEntry, UpdateRoomInput, VerifyRoomContext, Viewer, VoiceDecisionReason, VoiceDecisionSubject, VoiceRoomDecision, } from "./rooms";
|
|
13
14
|
export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, UseOpenProfileOptions, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
|
|
14
15
|
export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
|
|
16
|
+
export type { CreateStudySubjectInput, ListStudySessionsInput, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySessionEntry, StudySessionPage, StudySessionsResult, StudySubject, StudySubjectCreation, StudySubjectList, StudySubjectRefusal, StudySubjectRemoval, StudySubjectRemovalResponse, StudySubjectTotals, StudySubjectUpdate, StudySummary, UpdateStudySubjectInput, } from "./study";
|
|
15
17
|
export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
|
|
16
|
-
export type { ActionDayContext, ActionEventKind, ActionSessionState, ActionTransitionKind, ActiveActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
|
|
18
|
+
export type { ActionCatalogEntry, ActionCatalogResponse, ActionDayContext, ActionEventKind, ActionSessionState, ActionSubjectRef, ActionSurface, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, CurrentAction, FinishActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, StartActionResponse, StartUserSessionResult, UserActionSurface, XpGrantResult, XpMode, XpRecentGrant, XpSummary, } from "./xp";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RawRecord } from "../json";
|
|
2
|
+
import type { CurrentAction } from "./xp";
|
|
2
3
|
/** Room registry types shared by the server routes and the client SDK. */
|
|
3
4
|
export type RoomVisibility = "public" | "unlisted" | "private";
|
|
4
5
|
/**
|
|
@@ -93,6 +94,16 @@ export interface RoomMemberEntry {
|
|
|
93
94
|
* producers omit it; this one because a whole deployment might.
|
|
94
95
|
*/
|
|
95
96
|
isGuest?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The member's active action session as the roster may show it — "Studying",
|
|
99
|
+
* "Meditating" — or `null` when they have none. The subject rides inside
|
|
100
|
+
* only when that member shares it (`study.share_subject`).
|
|
101
|
+
*
|
|
102
|
+
* Optional for the reason `isGuest` is: this server always sends it, but a
|
|
103
|
+
* server older than the field omits it, and `undefined` must read as
|
|
104
|
+
* "unknown" rather than as "idle".
|
|
105
|
+
*/
|
|
106
|
+
currentAction?: CurrentAction | null;
|
|
96
107
|
role: RoomRole;
|
|
97
108
|
joinedAt: string;
|
|
98
109
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Study catalog and study-subject shapes that cross the server/SDK boundary. */
|
|
2
|
+
import type { ActionSubjectRef, ActionSurface } from "./xp";
|
|
3
|
+
/**
|
|
4
|
+
* One predefined thing a person can study. `slug` is `category/field`,
|
|
5
|
+
* lowercase ASCII and stable forever — it is what a subject row stores and
|
|
6
|
+
* what totals roll up on. `label` and `aliases` are display and search only,
|
|
7
|
+
* so either may change without touching data.
|
|
8
|
+
*/
|
|
9
|
+
export interface StudyField {
|
|
10
|
+
slug: string;
|
|
11
|
+
label: string;
|
|
12
|
+
aliases?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
export interface StudyCategory {
|
|
15
|
+
slug: string;
|
|
16
|
+
label: string;
|
|
17
|
+
fields: readonly StudyField[];
|
|
18
|
+
}
|
|
19
|
+
/** Response of GET /api/study/catalog. */
|
|
20
|
+
export interface StudyCatalog {
|
|
21
|
+
version: number;
|
|
22
|
+
categories: readonly StudyCategory[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One of the caller's study subjects, as every `/api/study/subjects` route
|
|
26
|
+
* returns it. `field` is a catalog field slug, a bare category slug (a custom
|
|
27
|
+
* subject that rolls up under one category), or null for a subject the catalog
|
|
28
|
+
* has no home for. `name` is the user's own words for it.
|
|
29
|
+
*/
|
|
30
|
+
export interface StudySubject {
|
|
31
|
+
id: string;
|
|
32
|
+
field: string | null;
|
|
33
|
+
name: string;
|
|
34
|
+
archivedAt: string | null;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
lastUsedAt: string;
|
|
37
|
+
}
|
|
38
|
+
/** Response of GET /api/study/subjects. */
|
|
39
|
+
export interface StudySubjectList {
|
|
40
|
+
subjects: StudySubject[];
|
|
41
|
+
}
|
|
42
|
+
/** Body of POST /api/study/subjects. `name` defaults to the field's label. */
|
|
43
|
+
export interface CreateStudySubjectInput {
|
|
44
|
+
field?: string | null;
|
|
45
|
+
name?: string;
|
|
46
|
+
}
|
|
47
|
+
/** Body of PATCH /api/study/subjects/:id — every key optional, at least one required. */
|
|
48
|
+
export interface UpdateStudySubjectInput {
|
|
49
|
+
name?: string;
|
|
50
|
+
field?: string | null;
|
|
51
|
+
archived?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** Why a subject write was refused; each is also the `error` code the route answers with. */
|
|
54
|
+
export type StudySubjectRefusal = "field_unknown" | "name_invalid" | "name_taken" | "subject_limit" | "not_found";
|
|
55
|
+
/** Outcome of a create: `created: false` means a live subject with that name
|
|
56
|
+
* already existed and is returned instead (the route answers 200, not 201). */
|
|
57
|
+
export type StudySubjectCreation = {
|
|
58
|
+
ok: true;
|
|
59
|
+
created: boolean;
|
|
60
|
+
subject: StudySubject;
|
|
61
|
+
} | {
|
|
62
|
+
ok: false;
|
|
63
|
+
reason: Exclude<StudySubjectRefusal, "not_found" | "name_taken">;
|
|
64
|
+
};
|
|
65
|
+
export type StudySubjectUpdate = {
|
|
66
|
+
ok: true;
|
|
67
|
+
subject: StudySubject;
|
|
68
|
+
} | {
|
|
69
|
+
ok: false;
|
|
70
|
+
reason: StudySubjectRefusal;
|
|
71
|
+
};
|
|
72
|
+
/** Outcome of a delete: a subject no session references is removed outright;
|
|
73
|
+
* one with history is archived instead and returned. */
|
|
74
|
+
export type StudySubjectRemoval = {
|
|
75
|
+
ok: true;
|
|
76
|
+
outcome: "deleted";
|
|
77
|
+
subject: null;
|
|
78
|
+
} | {
|
|
79
|
+
ok: true;
|
|
80
|
+
outcome: "archived";
|
|
81
|
+
subject: StudySubject;
|
|
82
|
+
} | {
|
|
83
|
+
ok: false;
|
|
84
|
+
reason: "not_found";
|
|
85
|
+
};
|
|
86
|
+
/** Response of DELETE /api/study/subjects/:id. */
|
|
87
|
+
export type StudySubjectRemovalResponse = Extract<StudySubjectRemoval, {
|
|
88
|
+
ok: true;
|
|
89
|
+
}>;
|
|
90
|
+
/** The window GET /api/study/summary totals over. Days are UTC calendar days,
|
|
91
|
+
* like the XP curve's: `today` is since UTC midnight, `7d` and `30d` are the
|
|
92
|
+
* 7 and 30 UTC days ending today, `all` is unbounded. */
|
|
93
|
+
export type StudyRange = "today" | "7d" | "30d" | "all";
|
|
94
|
+
/** One subject's finished study time in a range. `lastAt` is the start of
|
|
95
|
+
* the most recent session counted. The subject resolves whether or not it has
|
|
96
|
+
* since been archived, so history keeps its name. */
|
|
97
|
+
export interface StudySubjectTotals {
|
|
98
|
+
subject: ActionSubjectRef;
|
|
99
|
+
seconds: number;
|
|
100
|
+
focusedSeconds: number;
|
|
101
|
+
sessions: number;
|
|
102
|
+
lastAt: string;
|
|
103
|
+
}
|
|
104
|
+
/** One catalog category's finished study time in a range: the sum over every
|
|
105
|
+
* subject whose `field` is that category or one of its fields. */
|
|
106
|
+
export interface StudyCategoryTotals {
|
|
107
|
+
slug: string;
|
|
108
|
+
label: string;
|
|
109
|
+
seconds: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Response of GET /api/study/summary: finished `action.study` sessions only,
|
|
113
|
+
* summed from the durations materialized at finish. `subjects` and
|
|
114
|
+
* `categories` are ordered by seconds descending. Sessions without a subject
|
|
115
|
+
* (a game Study started while the user had none) count in `total` only.
|
|
116
|
+
*/
|
|
117
|
+
export interface StudySummary {
|
|
118
|
+
range: StudyRange;
|
|
119
|
+
subjects: StudySubjectTotals[];
|
|
120
|
+
categories: StudyCategoryTotals[];
|
|
121
|
+
total: {
|
|
122
|
+
seconds: number;
|
|
123
|
+
sessions: number;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** One finished study session as GET /api/study/sessions lists it. */
|
|
127
|
+
export interface StudySessionEntry {
|
|
128
|
+
sessionId: string;
|
|
129
|
+
startedAt: string;
|
|
130
|
+
finishedAt: string;
|
|
131
|
+
seconds: number;
|
|
132
|
+
focusedSeconds: number;
|
|
133
|
+
xpGranted: number;
|
|
134
|
+
surface: ActionSurface;
|
|
135
|
+
}
|
|
136
|
+
/** Response of GET /api/study/sessions: newest first, `nextBefore` is the
|
|
137
|
+
* `before` of the next page or null on the last one. */
|
|
138
|
+
export interface StudySessionPage {
|
|
139
|
+
sessions: StudySessionEntry[];
|
|
140
|
+
nextBefore: string | null;
|
|
141
|
+
}
|
|
142
|
+
export interface ListStudySessionsInput {
|
|
143
|
+
subjectId: string;
|
|
144
|
+
/** Exclusive upper bound on `startedAt`; omit for the newest page. */
|
|
145
|
+
before?: Date;
|
|
146
|
+
limit: number;
|
|
147
|
+
}
|
|
148
|
+
export type StudySessionsResult = {
|
|
149
|
+
ok: true;
|
|
150
|
+
page: StudySessionPage;
|
|
151
|
+
} | {
|
|
152
|
+
ok: false;
|
|
153
|
+
reason: "not_found";
|
|
154
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** Study catalog and study-subject shapes that cross the server/SDK boundary. */
|
package/dist/sdk/types/xp.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
/** XP / leveling shapes that cross the server/dialog boundary. */
|
|
2
|
+
/** How a source turns activity into XP: a flat amount per grant, XP per
|
|
3
|
+
* minute, or a day-saturating curve. The curves themselves live in
|
|
4
|
+
* `src/xp/registry.ts`. */
|
|
5
|
+
export type XpMode = "fixed" | "linear" | "consistency";
|
|
2
6
|
export interface LevelProgress {
|
|
3
7
|
/** Level within the current tier, 1..100 — it resets to 1 on each tier-up. */
|
|
4
8
|
level: number;
|
|
@@ -52,15 +56,95 @@ export type ActionSessionState = "active" | "finished" | "abandoned";
|
|
|
52
56
|
export type ActionEventKind = "start" | "pause" | "resume" | "focus_lost" | "focus_gained" | "finish" | "abandon";
|
|
53
57
|
/** Reporter-supplied transitions accepted while an action is active. */
|
|
54
58
|
export type ActionTransitionKind = Extract<ActionEventKind, "pause" | "resume" | "focus_lost" | "focus_gained">;
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `
|
|
59
|
+
/** Where a session was started: the game world under the report token, or a
|
|
60
|
+
* web surface under the caller's own session. Recorded in
|
|
61
|
+
* `action_session.metadata.surface`; the source id stays surface-agnostic, so
|
|
62
|
+
* Meditate is `game.action.meditation` whether it began in the game or in
|
|
63
|
+
* Talk. */
|
|
64
|
+
export type ActionSurface = "game" | "talk" | "web";
|
|
65
|
+
/** The surfaces a caller may claim on POST /api/actions/me/start; `game` is
|
|
66
|
+
* reserved for the report-token path. */
|
|
67
|
+
export type UserActionSurface = Exclude<ActionSurface, "game">;
|
|
68
|
+
/** The study subject a session is attributed to, resolved from the joined
|
|
69
|
+
* `study_subject` row whether or not it has since been archived, so history
|
|
70
|
+
* keeps its name. Null for a source without subjects and for a Study the
|
|
71
|
+
* game started while the user had none. */
|
|
72
|
+
export interface ActionSubjectRef {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
field: string | null;
|
|
76
|
+
}
|
|
77
|
+
/** The caller's own view of one session — the shape of GET /api/actions/me
|
|
78
|
+
* and /active (null when idle), of the `session` a start returns, and of the
|
|
79
|
+
* `active` a 409 carries — plus the day context so a timer can render the
|
|
80
|
+
* live XP estimate from the timestamp-derived accrued duration. The lifecycle
|
|
81
|
+
* state is not carried: an idle caller reads null, and a start or finish
|
|
82
|
+
* answers with the session as it stands at that instant. */
|
|
59
83
|
export interface ActiveActionResponse extends ActionDayContext {
|
|
84
|
+
sessionId: string;
|
|
60
85
|
source: string;
|
|
61
86
|
label: string | null;
|
|
87
|
+
surface: ActionSurface;
|
|
88
|
+
/** ISO timestamp of the session's start. */
|
|
89
|
+
startedAt: string;
|
|
90
|
+
/** Whether the last pause/resume transition left the clock stopped. */
|
|
91
|
+
paused: boolean;
|
|
62
92
|
accruedSeconds: number;
|
|
93
|
+
subject: ActionSubjectRef | null;
|
|
94
|
+
}
|
|
95
|
+
/** One user-startable action as GET /api/actions/catalog lists it: the
|
|
96
|
+
* registry descriptor minus its reporter, with the curve parameters a client
|
|
97
|
+
* needs to estimate XP the way the server grants it. `label` and
|
|
98
|
+
* `description` are always present (a source without them lists under its
|
|
99
|
+
* id); the curve fields are present only when the source declares them. */
|
|
100
|
+
export interface ActionCatalogEntry {
|
|
101
|
+
id: string;
|
|
102
|
+
label: string;
|
|
103
|
+
description: string;
|
|
104
|
+
mode: XpMode;
|
|
105
|
+
base: number;
|
|
106
|
+
tauMinutes?: number;
|
|
107
|
+
dailyCapXp?: number;
|
|
108
|
+
minDurationSeconds?: number;
|
|
109
|
+
flowMultiplier: boolean;
|
|
110
|
+
userStartable: boolean;
|
|
111
|
+
requiresSubject: boolean;
|
|
112
|
+
}
|
|
113
|
+
/** Response of GET /api/actions/catalog, in registry declaration order. */
|
|
114
|
+
export interface ActionCatalogResponse {
|
|
115
|
+
actions: ActionCatalogEntry[];
|
|
116
|
+
}
|
|
117
|
+
/** Response of POST /api/actions/me/start (201 created, 200 on an idempotent
|
|
118
|
+
* replay of the same sessionId). */
|
|
119
|
+
export interface StartActionResponse {
|
|
120
|
+
session: ActiveActionResponse;
|
|
121
|
+
}
|
|
122
|
+
/** The 409 body of POST /api/actions/me/start: the session already running,
|
|
123
|
+
* which `replace: true` would finish. */
|
|
124
|
+
export interface ActiveSessionConflict {
|
|
125
|
+
error: "active_session";
|
|
126
|
+
active: ActiveActionResponse;
|
|
127
|
+
}
|
|
128
|
+
/** Response of POST /api/actions/me/finish. Idempotent: a replay answers with
|
|
129
|
+
* the XP and duration the session was credited when it ended. */
|
|
130
|
+
export interface FinishActionResponse {
|
|
131
|
+
ok: true;
|
|
132
|
+
xpGranted: number;
|
|
133
|
+
durationSeconds: number;
|
|
63
134
|
}
|
|
135
|
+
/** Outcome of a user-initiated start. The refusals map to one status each in
|
|
136
|
+
* the route; `subject_unknown` is a subjectId that is not one of the caller's
|
|
137
|
+
* live subjects, `session_id_taken` a client id that another user's session
|
|
138
|
+
* already holds, and `rate_limited` the per-user hourly start cap. */
|
|
139
|
+
export type StartUserSessionResult = {
|
|
140
|
+
status: "created" | "replayed";
|
|
141
|
+
session: ActiveActionResponse;
|
|
142
|
+
} | {
|
|
143
|
+
status: "active_session";
|
|
144
|
+
active: ActiveActionResponse;
|
|
145
|
+
} | {
|
|
146
|
+
status: "unknown_source" | "not_startable" | "subject_required" | "subject_unknown" | "session_id_taken" | "rate_limited";
|
|
147
|
+
};
|
|
64
148
|
/** Non-sensitive profile facts any player may see about another — nested into
|
|
65
149
|
* the /api/session/verify payload beside `appearance` so game servers can
|
|
66
150
|
* broadcast them (avatar + level badge on nametags) without extra fetches.
|
|
@@ -111,3 +195,20 @@ export type PublicProfileResult = {
|
|
|
111
195
|
reason: PublicProfileRefusal;
|
|
112
196
|
signal: PublicProfileSignal;
|
|
113
197
|
};
|
|
198
|
+
/**
|
|
199
|
+
* What a user is doing right now, as OTHER users see it — the room roster's
|
|
200
|
+
* view of an active `action_session`, not the owner's own
|
|
201
|
+
* {@link ActiveActionResponse}. Deliberately narrower: no clock, no XP
|
|
202
|
+
* context, no session id, because a reader has no session to drive. `label`
|
|
203
|
+
* is `null` for a source the registry no longer names, exactly as the owner's
|
|
204
|
+
* view reports it. `subject` is the study subject's name and is present only
|
|
205
|
+
* while the owner's `study.share_subject` setting is on — the subject is their
|
|
206
|
+
* own words ("Bar exam prep"), so its absence is the default, not a gap.
|
|
207
|
+
*/
|
|
208
|
+
export interface CurrentAction {
|
|
209
|
+
source: string;
|
|
210
|
+
label: string | null;
|
|
211
|
+
/** ISO timestamp of the session's start. */
|
|
212
|
+
since: string;
|
|
213
|
+
subject?: string;
|
|
214
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure XP curve math: the Flow Score multiplier, the day-saturating
|
|
3
|
+
* consistency curve, and the live estimate a timer renders from them. No
|
|
4
|
+
* registry, no DB, no I/O — this module is shipped in the SDK and imported by
|
|
5
|
+
* the grant service alike, so a count-up in a consumer app and the ledger row
|
|
6
|
+
* the server writes come from one function and cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
import type { ActionCurve, XpEstimateContext } from "../types";
|
|
9
|
+
export declare const FLOW_SCORE_MAX = 10;
|
|
10
|
+
export declare function clampFlowScore(score: number): number;
|
|
11
|
+
/** The Flow Score IS the XP multiplier: score 10 grants 10x. Floored at 1x so a
|
|
12
|
+
* lapsed player (score 0 or 1) still earns full base XP. */
|
|
13
|
+
export declare function flowMultiplier(flowScore: number): number;
|
|
14
|
+
/** The (unrounded) cumulative pre-multiplier XP a consistency source has earned
|
|
15
|
+
* after `totalMinutes` of activity in the day — a saturating curve toward
|
|
16
|
+
* `base`. A single session's grant is the delta between this at the new and old
|
|
17
|
+
* totals, so the first minutes of the day are worth the most. The live timer
|
|
18
|
+
* uses this raw form for a smooth count-up; grants round it. */
|
|
19
|
+
export declare function consistencyCurve(source: Pick<ActionCurve, "base" | "tauMinutes">, totalMinutes: number): number;
|
|
20
|
+
/**
|
|
21
|
+
* The XP a session of `elapsedSeconds` would grant if it finished now,
|
|
22
|
+
* mirroring the grant service: consistency is the delta on the day-cumulative
|
|
23
|
+
* curve from today's prior minutes, linear is `base` per minute, fixed is flat;
|
|
24
|
+
* below the source's minimum duration it is 0. The Flow Score multiplies the
|
|
25
|
+
* result, and a daily cap bounds it by what is left of the day's ceiling after
|
|
26
|
+
* the prior minutes. Unrounded curve, rounded once at the end, so a count-up
|
|
27
|
+
* advances smoothly and lands on the number the ledger will show.
|
|
28
|
+
*/
|
|
29
|
+
export declare function estimateActionXp(entry: ActionCurve, context: XpEstimateContext): number;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure XP curve math: the Flow Score multiplier, the day-saturating
|
|
3
|
+
* consistency curve, and the live estimate a timer renders from them. No
|
|
4
|
+
* registry, no DB, no I/O — this module is shipped in the SDK and imported by
|
|
5
|
+
* the grant service alike, so a count-up in a consumer app and the ledger row
|
|
6
|
+
* the server writes come from one function and cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
export const FLOW_SCORE_MAX = 10;
|
|
9
|
+
export function clampFlowScore(score) {
|
|
10
|
+
return Math.min(FLOW_SCORE_MAX, Math.max(0, Math.trunc(score)));
|
|
11
|
+
}
|
|
12
|
+
/** The Flow Score IS the XP multiplier: score 10 grants 10x. Floored at 1x so a
|
|
13
|
+
* lapsed player (score 0 or 1) still earns full base XP. */
|
|
14
|
+
export function flowMultiplier(flowScore) {
|
|
15
|
+
return Math.max(1, clampFlowScore(flowScore));
|
|
16
|
+
}
|
|
17
|
+
/** The (unrounded) cumulative pre-multiplier XP a consistency source has earned
|
|
18
|
+
* after `totalMinutes` of activity in the day — a saturating curve toward
|
|
19
|
+
* `base`. A single session's grant is the delta between this at the new and old
|
|
20
|
+
* totals, so the first minutes of the day are worth the most. The live timer
|
|
21
|
+
* uses this raw form for a smooth count-up; grants round it. */
|
|
22
|
+
export function consistencyCurve(source, totalMinutes) {
|
|
23
|
+
const tau = source.tauMinutes ?? 40;
|
|
24
|
+
return source.base * (1 - Math.exp(-Math.max(0, totalMinutes) / tau));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The XP a session of `elapsedSeconds` would grant if it finished now,
|
|
28
|
+
* mirroring the grant service: consistency is the delta on the day-cumulative
|
|
29
|
+
* curve from today's prior minutes, linear is `base` per minute, fixed is flat;
|
|
30
|
+
* below the source's minimum duration it is 0. The Flow Score multiplies the
|
|
31
|
+
* result, and a daily cap bounds it by what is left of the day's ceiling after
|
|
32
|
+
* the prior minutes. Unrounded curve, rounded once at the end, so a count-up
|
|
33
|
+
* advances smoothly and lands on the number the ledger will show.
|
|
34
|
+
*/
|
|
35
|
+
export function estimateActionXp(entry, context) {
|
|
36
|
+
const { elapsedSeconds, priorMinutes, flowScore } = context;
|
|
37
|
+
if (entry.minDurationSeconds !== undefined &&
|
|
38
|
+
elapsedSeconds < entry.minDurationSeconds) {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
const elapsedMinutes = Math.max(0, elapsedSeconds) / 60;
|
|
42
|
+
const prior = Math.max(0, priorMinutes);
|
|
43
|
+
const multiplier = entry.flowMultiplier ? flowMultiplier(flowScore) : 1;
|
|
44
|
+
let base;
|
|
45
|
+
let priorBase = 0;
|
|
46
|
+
if (entry.mode === "fixed") {
|
|
47
|
+
base = entry.base;
|
|
48
|
+
}
|
|
49
|
+
else if (entry.mode === "linear") {
|
|
50
|
+
base = entry.base * elapsedMinutes;
|
|
51
|
+
priorBase = entry.base * prior;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
base =
|
|
55
|
+
consistencyCurve(entry, prior + elapsedMinutes) -
|
|
56
|
+
consistencyCurve(entry, prior);
|
|
57
|
+
priorBase = consistencyCurve(entry, prior);
|
|
58
|
+
}
|
|
59
|
+
const xp = Math.max(0, Math.round(base * multiplier));
|
|
60
|
+
if (entry.dailyCapXp === undefined)
|
|
61
|
+
return xp;
|
|
62
|
+
const remaining = Math.max(0, entry.dailyCapXp - Math.round(priorBase * multiplier));
|
|
63
|
+
return Math.min(xp, remaining);
|
|
64
|
+
}
|