@elizaos/plugin-form 2.0.3-beta.5 → 2.0.3-beta.7
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/actions/form.d.ts +31 -0
- package/dist/actions/form.d.ts.map +1 -0
- package/dist/actions/form.js +187 -0
- package/dist/actions/form.js.map +1 -0
- package/dist/builder.d.ts +320 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +458 -0
- package/dist/builder.js.map +1 -0
- package/dist/builtins.d.ts +128 -0
- package/dist/builtins.d.ts.map +1 -0
- package/dist/builtins.js +233 -0
- package/dist/builtins.js.map +1 -0
- package/dist/defaults.d.ts +95 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +79 -0
- package/dist/defaults.js.map +1 -0
- package/dist/evaluators/extractor.d.ts +28 -0
- package/dist/evaluators/extractor.d.ts.map +1 -0
- package/dist/evaluators/extractor.js +251 -0
- package/dist/evaluators/extractor.js.map +1 -0
- package/dist/extraction.d.ts +55 -0
- package/dist/extraction.d.ts.map +1 -0
- package/dist/extraction.js +347 -0
- package/dist/extraction.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/context.d.ts +56 -0
- package/dist/providers/context.d.ts.map +1 -0
- package/dist/providers/context.js +204 -0
- package/dist/providers/context.js.map +1 -0
- package/dist/service.d.ts +402 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +1199 -0
- package/dist/service.js.map +1 -0
- package/dist/storage.d.ts +228 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +255 -0
- package/dist/storage.js.map +1 -0
- package/dist/template.d.ts +10 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +60 -0
- package/dist/template.js.map +1 -0
- package/dist/ttl.d.ts +144 -0
- package/dist/ttl.d.ts.map +1 -0
- package/dist/ttl.js +85 -0
- package/dist/ttl.js.map +1 -0
- package/dist/types.d.ts +1213 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +39 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +156 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +289 -0
- package/dist/validation.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/storage.ts"],"sourcesContent":["/**\n * @module storage\n * @description Component-based persistence for form data\n *\n * ## Design Rationale\n *\n * Form data is stored using elizaOS's Component system because:\n *\n * 1. **Entity-Scoped**: Components belong to entities (users).\n * This naturally scopes form data per-user.\n *\n * 2. **Typed Storage**: Component type field allows different kinds\n * of form data (sessions, submissions, autofill).\n *\n * 3. **No Custom Schema**: Uses existing elizaOS infrastructure,\n * no need to create database tables.\n *\n * 4. **Room Scoping**: Component type includes roomId for session\n * isolation across rooms.\n *\n * ## Storage Strategy\n *\n * ### Sessions\n * - Stored as components with type: `form_session:{roomId}`\n * - One active session per user per room\n * - Scoping ensures different rooms have different contexts\n *\n * ### Submissions\n * - Stored as components with type: `form_submission:{formId}:{submissionId}`\n * - Immutable records of completed forms\n * - Multiple submissions per user (if form allows)\n *\n * ### Autofill\n * - Stored as components with type: `form_autofill:{formId}`\n * - One autofill record per user per form\n * - Updated on each submission\n *\n * ## Component-store tradeoffs\n *\n * The component-backed implementation has two important scaling properties:\n *\n * 1. **No Cross-Entity Queries**: Can't efficiently find all stale\n * sessions across all users. This affects nudge system.\n *\n * 2. **No Indexes**: Component queries are sequential scans.\n * High-volume deployments should add database-level optimizations.\n *\n * These tradeoffs keep the plugin self-contained on the elizaOS component\n * store while preserving a clear path for deployments that need indexed\n * operational queries.\n */\n\nimport type { Component, IAgentRuntime, JsonValue, UUID } from \"@elizaos/core\";\nimport { v4 as uuidv4 } from \"uuid\";\nimport { isExpired, isExpiringSoon } from \"./ttl.js\";\nimport type { FormAutofillData, FormSession, FormSubmission } from \"./types.js\";\nimport {\n FORM_AUTOFILL_COMPONENT,\n FORM_SESSION_COMPONENT,\n FORM_SUBMISSION_COMPONENT,\n} from \"./types.js\";\n\nconst isRecord = (\n value: JsonValue | object,\n): value is Record<string, JsonValue> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst resolveComponentContext = async (\n runtime: IAgentRuntime,\n roomId?: UUID,\n): Promise<{ roomId: UUID; worldId: UUID }> => {\n if (roomId) {\n const room = await runtime.getRoom(roomId);\n return { roomId, worldId: room?.worldId ?? runtime.agentId };\n }\n return { roomId: runtime.agentId, worldId: runtime.agentId };\n};\n\nconst isFormSession = (data: JsonValue | object): data is FormSession => {\n if (!isRecord(data)) return false;\n return (\n typeof data.id === \"string\" &&\n typeof data.formId === \"string\" &&\n typeof data.entityId === \"string\" &&\n typeof data.roomId === \"string\"\n );\n};\n\nconst isLiveSession = (session: FormSession): boolean => !isExpired(session);\n\nconst RESTORABLE_SESSION_STATUSES: FormSession[\"status\"][] = [\n \"active\",\n \"ready\",\n \"stashed\",\n];\nconst RESTORABLE_SESSION_SCAN_LIMIT = 100;\n\nconst isFormSubmission = (data: JsonValue | object): data is FormSubmission => {\n if (!isRecord(data)) return false;\n return (\n typeof data.id === \"string\" &&\n typeof data.formId === \"string\" &&\n typeof data.sessionId === \"string\" &&\n typeof data.entityId === \"string\"\n );\n};\n\nconst isFormAutofillData = (\n data: JsonValue | object,\n): data is FormAutofillData => {\n if (!isRecord(data)) return false;\n return (\n typeof data.formId === \"string\" &&\n typeof data.updatedAt === \"number\" &&\n typeof data.values === \"object\"\n );\n};\n\nconst getRestorableSessions = async (\n runtime: IAgentRuntime,\n): Promise<FormSession[]> => {\n const sessionsById = new Map<string, FormSession>();\n let offset = 0;\n\n while (true) {\n const entities = await runtime.queryEntities({\n agentId: runtime.agentId,\n includeAllComponents: true,\n limit: RESTORABLE_SESSION_SCAN_LIMIT,\n offset,\n });\n\n for (const entity of entities) {\n for (const component of entity.components ?? []) {\n if (!component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {\n continue;\n }\n if (component.data && isFormSession(component.data)) {\n const session = component.data;\n if (\n isLiveSession(session) &&\n RESTORABLE_SESSION_STATUSES.includes(session.status)\n ) {\n sessionsById.set(session.id, session);\n }\n }\n }\n }\n\n if (entities.length < RESTORABLE_SESSION_SCAN_LIMIT) {\n break;\n }\n offset += RESTORABLE_SESSION_SCAN_LIMIT;\n }\n\n return [...sessionsById.values()];\n};\n\n// ============================================================================\n// SESSION STORAGE\n// ============================================================================\n\n/**\n * Get active form session for entity in a specific room.\n *\n * WHY room-scoped:\n * - User might chat in multiple rooms simultaneously\n * - Each room conversation should have its own form context\n * - Discord DM form shouldn't interfere with Telegram form\n *\n * WHY active/ready filter:\n * - Stashed, submitted, cancelled, expired sessions are not \"active\"\n * - User would need to restore stashed sessions\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @param roomId - The room to check for active session\n * @returns Active session or null if none\n */\nexport async function getActiveSession(\n runtime: IAgentRuntime,\n entityId: UUID,\n roomId: UUID,\n): Promise<FormSession | null> {\n // Component type includes roomId for room-level scoping\n const component = await runtime.getComponent(\n entityId,\n `${FORM_SESSION_COMPONENT}:${roomId}`,\n );\n\n if (!component?.data || !isFormSession(component.data)) return null;\n\n const session = component.data;\n\n // Only return if active (not stashed, submitted, cancelled, or expired)\n // WHY: Other statuses require explicit action to restore/continue\n if (\n isLiveSession(session) &&\n (session.status === \"active\" || session.status === \"ready\")\n ) {\n return session;\n }\n\n return null;\n}\n\n/**\n * Get all active sessions for an entity (across all rooms).\n *\n * WHY this exists:\n * - For \"you have forms in progress\" notifications\n * - For session management UI\n * - Not commonly used in normal flow\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @returns Array of active sessions (may be empty)\n */\nexport async function getAllActiveSessions(\n runtime: IAgentRuntime,\n entityId: UUID,\n): Promise<FormSession[]> {\n const components = await runtime.getComponents(entityId);\n\n const sessions: FormSession[] = [];\n for (const component of components) {\n // Check if this is a form session component\n if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {\n if (component.data && isFormSession(component.data)) {\n const session = component.data;\n if (\n isLiveSession(session) &&\n (session.status === \"active\" || session.status === \"ready\")\n ) {\n sessions.push(session);\n }\n }\n }\n }\n\n return sessions;\n}\n\n/**\n * Get stashed sessions for an entity.\n *\n * WHY stashed is separate from active:\n * - Stashed sessions are \"saved for later\"\n * - User must explicitly restore them\n * - Different UX from active sessions\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @returns Array of stashed sessions (may be empty)\n */\nexport async function getStashedSessions(\n runtime: IAgentRuntime,\n entityId: UUID,\n): Promise<FormSession[]> {\n const components = await runtime.getComponents(entityId);\n\n const sessions: FormSession[] = [];\n for (const component of components) {\n if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {\n if (component.data && isFormSession(component.data)) {\n const session = component.data;\n if (isLiveSession(session) && session.status === \"stashed\") {\n sessions.push(session);\n }\n }\n }\n }\n\n return sessions;\n}\n\n/**\n * Get a session by ID.\n *\n * WHY by ID:\n * - Needed for operations on specific session\n * - Session ID is stable across room changes\n * - Used by stash/restore when session roomId changes\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @param sessionId - The session ID to find\n * @returns The session or null if not found\n */\nexport async function getSessionById(\n runtime: IAgentRuntime,\n entityId: UUID,\n sessionId: string,\n): Promise<FormSession | null> {\n const components = await runtime.getComponents(entityId);\n\n for (const component of components) {\n if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {\n if (component.data && isFormSession(component.data)) {\n const session = component.data;\n if (isLiveSession(session) && session.id === sessionId) {\n return session;\n }\n }\n }\n }\n\n return null;\n}\n\n/**\n * Save a form session.\n *\n * Creates new component if none exists, updates otherwise.\n *\n * WHY upsert pattern:\n * - Session is created once, updated many times\n * - Single function handles both cases\n * - Avoids race conditions\n *\n * @param runtime - Agent runtime for database access\n * @param session - Session to save\n */\nexport async function saveSession(\n runtime: IAgentRuntime,\n session: FormSession,\n): Promise<void> {\n const componentType = `${FORM_SESSION_COMPONENT}:${session.roomId}`;\n const existing = await runtime.getComponent(session.entityId, componentType);\n const context = await resolveComponentContext(runtime, session.roomId);\n const resolvedWorldId = existing?.worldId ?? context.worldId;\n\n const component: Component = {\n id: existing?.id || (uuidv4() as UUID),\n entityId: session.entityId,\n agentId: runtime.agentId,\n roomId: session.roomId,\n // WHY preserve worldId: Avoids breaking existing component relationships\n worldId: resolvedWorldId,\n sourceEntityId: runtime.agentId,\n type: componentType,\n createdAt: existing?.createdAt || Date.now(),\n // Store session as component data\n data: JSON.parse(JSON.stringify(session)) as Record<string, JsonValue>,\n };\n\n if (existing) {\n await runtime.updateComponent(component);\n } else {\n await runtime.createComponent(component);\n }\n}\n\n/**\n * Delete a session.\n *\n * WHY delete:\n * - Cleanup after submission/cancellation/expiry\n * - Frees up storage\n * - Note: Usually we just change status instead of deleting\n *\n * @param runtime - Agent runtime for database access\n * @param session - Session to delete\n */\nexport async function deleteSession(\n runtime: IAgentRuntime,\n session: FormSession,\n): Promise<void> {\n const componentType = `${FORM_SESSION_COMPONENT}:${session.roomId}`;\n const existing = await runtime.getComponent(session.entityId, componentType);\n\n if (existing) {\n await runtime.deleteComponent(existing.id);\n }\n}\n\n// ============================================================================\n// SUBMISSION STORAGE\n// ============================================================================\n\n/**\n * Save a form submission.\n *\n * Submissions are immutable records. Always creates new component.\n *\n * WHY new component per submission:\n * - Submissions are immutable\n * - Multiple submissions allowed (if form permits)\n * - Historical record keeping\n *\n * @param runtime - Agent runtime for database access\n * @param submission - Submission to save\n */\nexport async function saveSubmission(\n runtime: IAgentRuntime,\n submission: FormSubmission,\n): Promise<void> {\n // Use a unique component type per submission\n // WHY: Allows multiple submissions per form\n const componentType = `${FORM_SUBMISSION_COMPONENT}:${submission.formId}:${submission.id}`;\n const context = await resolveComponentContext(runtime);\n\n const component: Component = {\n id: uuidv4() as UUID,\n entityId: submission.entityId,\n agentId: runtime.agentId,\n roomId: context.roomId,\n worldId: context.worldId,\n sourceEntityId: runtime.agentId,\n type: componentType,\n createdAt: submission.submittedAt,\n data: JSON.parse(JSON.stringify(submission)) as Record<string, JsonValue>,\n };\n\n await runtime.createComponent(component);\n}\n\n/**\n * Get submissions for an entity, optionally filtered by form ID.\n *\n * WHY optional formId:\n * - List all submissions: no formId\n * - List submissions for specific form: with formId\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @param formId - Optional form ID filter\n * @returns Array of submissions, newest first\n */\nexport async function getSubmissions(\n runtime: IAgentRuntime,\n entityId: UUID,\n formId?: string,\n): Promise<FormSubmission[]> {\n const components = await runtime.getComponents(entityId);\n\n const submissions: FormSubmission[] = [];\n const prefix = formId\n ? `${FORM_SUBMISSION_COMPONENT}:${formId}:`\n : `${FORM_SUBMISSION_COMPONENT}:`;\n\n for (const component of components) {\n if (component.type.startsWith(prefix)) {\n if (component.data && isFormSubmission(component.data)) {\n submissions.push(component.data);\n }\n }\n }\n\n // Sort by submission time, newest first\n // WHY: Most recent submissions are usually most relevant\n submissions.sort((a, b) => b.submittedAt - a.submittedAt);\n\n return submissions;\n}\n\n/**\n * Get a specific submission by ID.\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @param submissionId - The submission ID to find\n * @returns The submission or null if not found\n */\nexport async function getSubmissionById(\n runtime: IAgentRuntime,\n entityId: UUID,\n submissionId: string,\n): Promise<FormSubmission | null> {\n const components = await runtime.getComponents(entityId);\n\n for (const component of components) {\n if (component.type.startsWith(`${FORM_SUBMISSION_COMPONENT}:`)) {\n if (component.data && isFormSubmission(component.data)) {\n const submission = component.data;\n if (submission.id === submissionId) {\n return submission;\n }\n }\n }\n }\n\n return null;\n}\n\n// ============================================================================\n// AUTOFILL STORAGE\n// ============================================================================\n\n/**\n * Get autofill data for a user's form.\n *\n * WHY autofill:\n * - Users filling repeat forms want saved values\n * - Reduces friction for common fields (name, email, address)\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @param formId - Form definition ID\n * @returns Autofill data or null if none saved\n */\nexport async function getAutofillData(\n runtime: IAgentRuntime,\n entityId: UUID,\n formId: string,\n): Promise<FormAutofillData | null> {\n const componentType = `${FORM_AUTOFILL_COMPONENT}:${formId}`;\n const component = await runtime.getComponent(entityId, componentType);\n\n if (!component?.data || !isFormAutofillData(component.data)) return null;\n\n return component.data;\n}\n\n/**\n * Save autofill data for a user's form.\n *\n * Overwrites existing autofill data for the form.\n *\n * WHY overwrite:\n * - Most recent submission has most current data\n * - User's email might have changed\n * - Only one autofill record per form needed\n *\n * @param runtime - Agent runtime for database access\n * @param entityId - User's entity ID\n * @param formId - Form definition ID\n * @param values - Field values to save for autofill\n */\nexport async function saveAutofillData(\n runtime: IAgentRuntime,\n entityId: UUID,\n formId: string,\n values: Record<string, JsonValue>,\n): Promise<void> {\n const componentType = `${FORM_AUTOFILL_COMPONENT}:${formId}`;\n const existing = await runtime.getComponent(entityId, componentType);\n const context = await resolveComponentContext(runtime);\n const resolvedWorldId = existing?.worldId ?? context.worldId;\n\n const data: FormAutofillData = {\n formId,\n values,\n updatedAt: Date.now(),\n };\n\n const component: Component = {\n id: existing?.id || (uuidv4() as UUID),\n entityId,\n agentId: runtime.agentId,\n roomId: context.roomId,\n worldId: resolvedWorldId,\n sourceEntityId: runtime.agentId,\n type: componentType,\n createdAt: existing?.createdAt || Date.now(),\n data: JSON.parse(JSON.stringify(data)) as Record<string, JsonValue>,\n };\n\n if (existing) {\n await runtime.updateComponent(component);\n } else {\n await runtime.createComponent(component);\n }\n}\n\n// ============================================================================\n// QUERY HELPERS\n// ============================================================================\n\n/**\n * Get all stale sessions (for nudge system).\n *\n * WHY this is here:\n * - Finds active/ready/stashed sessions across users\n * - Uses a bounded entity scan so remote runtimes do not need to support\n * component-data filtering\n * - Filters by the typed session payload so unrelated components with\n * matching status values are ignored\n *\n * @param runtime - Agent runtime for database access\n * @param afterInactiveMs - Inactivity threshold in milliseconds\n * @returns Array of stale sessions\n */\nexport async function getStaleSessions(\n runtime: IAgentRuntime,\n afterInactiveMs: number,\n): Promise<FormSession[]> {\n const now = Date.now();\n const sessions = await getRestorableSessions(runtime);\n return sessions.filter(\n (session) => now - session.effort.lastInteractionAt >= afterInactiveMs,\n );\n}\n\n/**\n * Get sessions expiring within a time window.\n *\n * Same bounded-scan limitation as getStaleSessions.\n *\n * @param runtime - Agent runtime for database access\n * @param withinMs - Time window in milliseconds\n * @returns Array of expiring sessions\n */\nexport async function getExpiringSessions(\n runtime: IAgentRuntime,\n withinMs: number,\n): Promise<FormSession[]> {\n const sessions = await getRestorableSessions(runtime);\n return sessions.filter((session) => isExpiringSoon(session, withinMs));\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module template
|
|
3
|
+
* @description Simple template resolution for form-controlled prompts
|
|
4
|
+
*/
|
|
5
|
+
import type { FormControl, FormSession } from "./types";
|
|
6
|
+
export type TemplateValues = Record<string, string>;
|
|
7
|
+
export declare function buildTemplateValues(session: FormSession): TemplateValues;
|
|
8
|
+
export declare function renderTemplate(template: string | undefined, values: TemplateValues): string | undefined;
|
|
9
|
+
export declare function resolveControlTemplates(control: FormControl, values: TemplateValues): FormControl;
|
|
10
|
+
//# sourceMappingURL=template.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../src/template.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAExD,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAIpD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,WAAW,GAAG,cAAc,CAwBxE;AAED,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,cAAc,GACrB,MAAM,GAAG,SAAS,CASpB;AAED,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,WAAW,EACpB,MAAM,EAAE,cAAc,GACrB,WAAW,CAuBb"}
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const TEMPLATE_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
|
|
2
|
+
function buildTemplateValues(session) {
|
|
3
|
+
const values = {};
|
|
4
|
+
for (const [key, state] of Object.entries(session.fields)) {
|
|
5
|
+
const value = state.value;
|
|
6
|
+
if (typeof value === "string") {
|
|
7
|
+
values[key] = value;
|
|
8
|
+
} else if (typeof value === "number" || typeof value === "boolean") {
|
|
9
|
+
values[key] = String(value);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const context = session.context;
|
|
13
|
+
if (context && typeof context === "object" && !Array.isArray(context)) {
|
|
14
|
+
for (const [key, value] of Object.entries(context)) {
|
|
15
|
+
if (typeof value === "string") {
|
|
16
|
+
values[key] = value;
|
|
17
|
+
} else if (typeof value === "number" || typeof value === "boolean") {
|
|
18
|
+
values[key] = String(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
function renderTemplate(template, values) {
|
|
25
|
+
if (!template) {
|
|
26
|
+
return template;
|
|
27
|
+
}
|
|
28
|
+
return template.replace(TEMPLATE_PATTERN, (match, key) => {
|
|
29
|
+
const replacement = values[key];
|
|
30
|
+
return replacement !== void 0 ? replacement : match;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function resolveControlTemplates(control, values) {
|
|
34
|
+
const resolvedOptions = control.options?.map((option) => ({
|
|
35
|
+
...option,
|
|
36
|
+
label: renderTemplate(option.label, values) ?? option.label,
|
|
37
|
+
description: renderTemplate(option.description, values)
|
|
38
|
+
}));
|
|
39
|
+
const resolvedFields = control.fields?.map(
|
|
40
|
+
(field) => resolveControlTemplates(field, values)
|
|
41
|
+
);
|
|
42
|
+
return {
|
|
43
|
+
...control,
|
|
44
|
+
label: renderTemplate(control.label, values) ?? control.label,
|
|
45
|
+
description: renderTemplate(control.description, values),
|
|
46
|
+
askPrompt: renderTemplate(control.askPrompt, values),
|
|
47
|
+
example: renderTemplate(control.example, values),
|
|
48
|
+
extractHints: control.extractHints?.map(
|
|
49
|
+
(hint) => renderTemplate(hint, values) ?? hint
|
|
50
|
+
),
|
|
51
|
+
options: resolvedOptions,
|
|
52
|
+
fields: resolvedFields ?? control.fields
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
buildTemplateValues,
|
|
57
|
+
renderTemplate,
|
|
58
|
+
resolveControlTemplates
|
|
59
|
+
};
|
|
60
|
+
//# sourceMappingURL=template.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/template.ts"],"sourcesContent":["/**\n * @module template\n * @description Simple template resolution for form-controlled prompts\n */\n\nimport type { FormControl, FormSession } from \"./types.js\";\n\nexport type TemplateValues = Record<string, string>;\n\nconst TEMPLATE_PATTERN = /\\{\\{\\s*([a-zA-Z0-9_.-]+)\\s*\\}\\}/g;\n\nexport function buildTemplateValues(session: FormSession): TemplateValues {\n const values: TemplateValues = {};\n\n for (const [key, state] of Object.entries(session.fields)) {\n const value = state.value;\n if (typeof value === \"string\") {\n values[key] = value;\n } else if (typeof value === \"number\" || typeof value === \"boolean\") {\n values[key] = String(value);\n }\n }\n\n const context = session.context;\n if (context && typeof context === \"object\" && !Array.isArray(context)) {\n for (const [key, value] of Object.entries(context)) {\n if (typeof value === \"string\") {\n values[key] = value;\n } else if (typeof value === \"number\" || typeof value === \"boolean\") {\n values[key] = String(value);\n }\n }\n }\n\n return values;\n}\n\nexport function renderTemplate(\n template: string | undefined,\n values: TemplateValues,\n): string | undefined {\n if (!template) {\n return template;\n }\n\n return template.replace(TEMPLATE_PATTERN, (match, key) => {\n const replacement = values[key];\n return replacement !== undefined ? replacement : match;\n });\n}\n\nexport function resolveControlTemplates(\n control: FormControl,\n values: TemplateValues,\n): FormControl {\n const resolvedOptions = control.options?.map((option) => ({\n ...option,\n label: renderTemplate(option.label, values) ?? option.label,\n description: renderTemplate(option.description, values),\n }));\n\n const resolvedFields = control.fields?.map((field) =>\n resolveControlTemplates(field, values),\n );\n\n return {\n ...control,\n label: renderTemplate(control.label, values) ?? control.label,\n description: renderTemplate(control.description, values),\n askPrompt: renderTemplate(control.askPrompt, values),\n example: renderTemplate(control.example, values),\n extractHints: control.extractHints?.map(\n (hint) => renderTemplate(hint, values) ?? hint,\n ),\n options: resolvedOptions,\n fields: resolvedFields ?? control.fields,\n };\n}\n"],"mappings":"AASA,MAAM,mBAAmB;AAElB,SAAS,oBAAoB,SAAsC;AACxE,QAAM,SAAyB,CAAC;AAEhC,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,QAAQ,MAAM,GAAG;AACzD,UAAM,QAAQ,MAAM;AACpB,QAAI,OAAO,UAAU,UAAU;AAC7B,aAAO,GAAG,IAAI;AAAA,IAChB,WAAW,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAClE,aAAO,GAAG,IAAI,OAAO,KAAK;AAAA,IAC5B;AAAA,EACF;AAEA,QAAM,UAAU,QAAQ;AACxB,MAAI,WAAW,OAAO,YAAY,YAAY,CAAC,MAAM,QAAQ,OAAO,GAAG;AACrE,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AAClD,UAAI,OAAO,UAAU,UAAU;AAC7B,eAAO,GAAG,IAAI;AAAA,MAChB,WAAW,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAClE,eAAO,GAAG,IAAI,OAAO,KAAK;AAAA,MAC5B;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,eACd,UACA,QACoB;AACpB,MAAI,CAAC,UAAU;AACb,WAAO;AAAA,EACT;AAEA,SAAO,SAAS,QAAQ,kBAAkB,CAAC,OAAO,QAAQ;AACxD,UAAM,cAAc,OAAO,GAAG;AAC9B,WAAO,gBAAgB,SAAY,cAAc;AAAA,EACnD,CAAC;AACH;AAEO,SAAS,wBACd,SACA,QACa;AACb,QAAM,kBAAkB,QAAQ,SAAS,IAAI,CAAC,YAAY;AAAA,IACxD,GAAG;AAAA,IACH,OAAO,eAAe,OAAO,OAAO,MAAM,KAAK,OAAO;AAAA,IACtD,aAAa,eAAe,OAAO,aAAa,MAAM;AAAA,EACxD,EAAE;AAEF,QAAM,iBAAiB,QAAQ,QAAQ;AAAA,IAAI,CAAC,UAC1C,wBAAwB,OAAO,MAAM;AAAA,EACvC;AAEA,SAAO;AAAA,IACL,GAAG;AAAA,IACH,OAAO,eAAe,QAAQ,OAAO,MAAM,KAAK,QAAQ;AAAA,IACxD,aAAa,eAAe,QAAQ,aAAa,MAAM;AAAA,IACvD,WAAW,eAAe,QAAQ,WAAW,MAAM;AAAA,IACnD,SAAS,eAAe,QAAQ,SAAS,MAAM;AAAA,IAC/C,cAAc,QAAQ,cAAc;AAAA,MAClC,CAAC,SAAS,eAAe,MAAM,MAAM,KAAK;AAAA,IAC5C;AAAA,IACA,SAAS;AAAA,IACT,QAAQ,kBAAkB,QAAQ;AAAA,EACpC;AACF;","names":[]}
|
package/dist/ttl.d.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ttl
|
|
3
|
+
* @description Smart TTL (Time-To-Live) management for form sessions
|
|
4
|
+
*
|
|
5
|
+
* ## Design Philosophy
|
|
6
|
+
*
|
|
7
|
+
* Traditional form systems delete abandoned forms after a fixed time.
|
|
8
|
+
* This fails users who invest significant effort:
|
|
9
|
+
*
|
|
10
|
+
* - User A: Opens form, immediately abandons → 24h retention is fine
|
|
11
|
+
* - User B: Spends 2 hours filling complex form → 24h retention loses their work!
|
|
12
|
+
*
|
|
13
|
+
* ## Effort-Based TTL
|
|
14
|
+
*
|
|
15
|
+
* This module calculates TTL based on user effort:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* TTL = clamp(minDays, effortDays, maxDays)
|
|
19
|
+
*
|
|
20
|
+
* where:
|
|
21
|
+
* effortDays = minutesSpent * effortMultiplier
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Default values:
|
|
25
|
+
* - minDays: 14 (two weeks minimum)
|
|
26
|
+
* - maxDays: 90 (three months maximum)
|
|
27
|
+
* - effortMultiplier: 0.5 (10 minutes = 5 extra days)
|
|
28
|
+
*
|
|
29
|
+
* ## Examples
|
|
30
|
+
*
|
|
31
|
+
* | Time Spent | Extra Days | Total TTL |
|
|
32
|
+
* |------------|------------|-----------|
|
|
33
|
+
* | 0 min | 0 | 14 days |
|
|
34
|
+
* | 10 min | 5 | 14 days |
|
|
35
|
+
* | 30 min | 15 | 15 days |
|
|
36
|
+
* | 2 hours | 60 | 60 days |
|
|
37
|
+
* | 4 hours | 120 | 90 days |
|
|
38
|
+
*
|
|
39
|
+
* ## Nudge System
|
|
40
|
+
*
|
|
41
|
+
* The nudge system sends reminders for stale forms:
|
|
42
|
+
*
|
|
43
|
+
* 1. After 48 hours of inactivity (configurable)
|
|
44
|
+
* 2. Maximum 3 nudges (configurable)
|
|
45
|
+
* 3. At least 24 hours between nudges
|
|
46
|
+
*
|
|
47
|
+
* ## Expiration Warnings
|
|
48
|
+
*
|
|
49
|
+
* Before a session expires, we warn the user:
|
|
50
|
+
*
|
|
51
|
+
* - 24 hours before expiration
|
|
52
|
+
* - "Your form will expire in 1 day. Say 'resume' to keep working."
|
|
53
|
+
*
|
|
54
|
+
* This gives users a chance to save their work.
|
|
55
|
+
*/
|
|
56
|
+
import type { FormDefinition, FormSession } from "./types";
|
|
57
|
+
/**
|
|
58
|
+
* Calculate TTL based on user effort.
|
|
59
|
+
*
|
|
60
|
+
* The more time a user spends on a form, the longer we keep it.
|
|
61
|
+
* This prevents losing significant work while still cleaning up abandoned forms.
|
|
62
|
+
*
|
|
63
|
+
* WHY effort-based:
|
|
64
|
+
* - Respects user investment
|
|
65
|
+
* - Automatic cleanup of abandoned forms
|
|
66
|
+
* - No manual expiration management needed
|
|
67
|
+
*
|
|
68
|
+
* @param session - Current session with effort tracking
|
|
69
|
+
* @param form - Form definition with TTL configuration
|
|
70
|
+
* @returns Expiration timestamp (milliseconds since epoch)
|
|
71
|
+
*/
|
|
72
|
+
export declare function calculateTTL(session: FormSession, form?: FormDefinition): number;
|
|
73
|
+
/**
|
|
74
|
+
* Check if session should be nudged.
|
|
75
|
+
*
|
|
76
|
+
* Nudges are gentle reminders for stashed or inactive forms.
|
|
77
|
+
*
|
|
78
|
+
* WHY nudge:
|
|
79
|
+
* - Users forget about forms they started
|
|
80
|
+
* - Gentle reminders increase completion
|
|
81
|
+
* - But too many nudges are annoying
|
|
82
|
+
*
|
|
83
|
+
* @param session - Session to check
|
|
84
|
+
* @param form - Form definition with nudge configuration
|
|
85
|
+
* @returns true if a nudge should be sent
|
|
86
|
+
*/
|
|
87
|
+
export declare function shouldNudge(session: FormSession, form?: FormDefinition): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Check if session is expiring soon.
|
|
90
|
+
*
|
|
91
|
+
* Used to send expiration warnings before session is deleted.
|
|
92
|
+
*
|
|
93
|
+
* @param session - Session to check
|
|
94
|
+
* @param withinMs - Time window in milliseconds
|
|
95
|
+
* @returns true if session expires within the window
|
|
96
|
+
*/
|
|
97
|
+
export declare function isExpiringSoon(session: FormSession, withinMs: number): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Check if session has expired.
|
|
100
|
+
*
|
|
101
|
+
* @param session - Session to check
|
|
102
|
+
* @returns true if session has passed its expiration time
|
|
103
|
+
*/
|
|
104
|
+
export declare function isExpired(session: FormSession): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Check if we should confirm before canceling.
|
|
107
|
+
*
|
|
108
|
+
* High-effort sessions deserve a confirmation before abandonment.
|
|
109
|
+
*
|
|
110
|
+
* WHY confirm:
|
|
111
|
+
* - Prevent accidental loss of significant work
|
|
112
|
+
* - "Are you sure?" for forms user invested in
|
|
113
|
+
*
|
|
114
|
+
* @param session - Session to check
|
|
115
|
+
* @returns true if cancel should require confirmation
|
|
116
|
+
*/
|
|
117
|
+
export declare function shouldConfirmCancel(session: FormSession): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Format remaining time for user display.
|
|
120
|
+
*
|
|
121
|
+
* Produces human-readable strings like:
|
|
122
|
+
* - "14 days"
|
|
123
|
+
* - "3 hours"
|
|
124
|
+
* - "45 minutes"
|
|
125
|
+
* - "expired"
|
|
126
|
+
*
|
|
127
|
+
* @param session - Session to format
|
|
128
|
+
* @returns Human-readable time remaining
|
|
129
|
+
*/
|
|
130
|
+
export declare function formatTimeRemaining(session: FormSession): string;
|
|
131
|
+
/**
|
|
132
|
+
* Format effort for user display.
|
|
133
|
+
*
|
|
134
|
+
* Produces human-readable strings like:
|
|
135
|
+
* - "just started"
|
|
136
|
+
* - "5 minutes"
|
|
137
|
+
* - "2 hours"
|
|
138
|
+
* - "1h 30m"
|
|
139
|
+
*
|
|
140
|
+
* @param session - Session to format
|
|
141
|
+
* @returns Human-readable effort description
|
|
142
|
+
*/
|
|
143
|
+
export declare function formatEffort(session: FormSession): string;
|
|
144
|
+
//# sourceMappingURL=ttl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ttl.d.ts","sourceRoot":"","sources":["../src/ttl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAG3D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,WAAW,EACpB,IAAI,CAAC,EAAE,cAAc,GACpB,MAAM,CAwBR;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,WAAW,EACpB,IAAI,CAAC,EAAE,cAAc,GACpB,OAAO,CAsCT;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,MAAM,GACf,OAAO,CAET;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAEvD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAKjE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAuBhE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAmBzD"}
|
package/dist/ttl.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { FORM_DEFINITION_DEFAULTS } from "./types.js";
|
|
2
|
+
function calculateTTL(session, form) {
|
|
3
|
+
const config = form?.ttl || {};
|
|
4
|
+
const minDays = config.minDays ?? FORM_DEFINITION_DEFAULTS.ttl.minDays;
|
|
5
|
+
const maxDays = config.maxDays ?? FORM_DEFINITION_DEFAULTS.ttl.maxDays;
|
|
6
|
+
const multiplier = config.effortMultiplier ?? FORM_DEFINITION_DEFAULTS.ttl.effortMultiplier;
|
|
7
|
+
const minutesSpent = session.effort.timeSpentMs / 6e4;
|
|
8
|
+
const effortDays = minutesSpent * multiplier;
|
|
9
|
+
const ttlDays = Math.min(maxDays, Math.max(minDays, effortDays));
|
|
10
|
+
return Date.now() + ttlDays * 24 * 60 * 60 * 1e3;
|
|
11
|
+
}
|
|
12
|
+
function shouldNudge(session, form) {
|
|
13
|
+
const nudgeConfig = form?.nudge;
|
|
14
|
+
if (nudgeConfig?.enabled === false) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const maxNudges = nudgeConfig?.maxNudges ?? FORM_DEFINITION_DEFAULTS.nudge.maxNudges;
|
|
18
|
+
if ((session.nudgeCount || 0) >= maxNudges) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const afterInactiveHours = nudgeConfig?.afterInactiveHours ?? FORM_DEFINITION_DEFAULTS.nudge.afterInactiveHours;
|
|
22
|
+
const inactiveMs = afterInactiveHours * 60 * 60 * 1e3;
|
|
23
|
+
const timeSinceInteraction = Date.now() - session.effort.lastInteractionAt;
|
|
24
|
+
if (timeSinceInteraction < inactiveMs) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (session.lastNudgeAt) {
|
|
28
|
+
const timeSinceNudge = Date.now() - session.lastNudgeAt;
|
|
29
|
+
if (timeSinceNudge < 24 * 60 * 60 * 1e3) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
function isExpiringSoon(session, withinMs) {
|
|
36
|
+
return session.expiresAt - Date.now() < withinMs;
|
|
37
|
+
}
|
|
38
|
+
function isExpired(session) {
|
|
39
|
+
return session.expiresAt < Date.now();
|
|
40
|
+
}
|
|
41
|
+
function shouldConfirmCancel(session) {
|
|
42
|
+
const minEffortMs = 5 * 60 * 1e3;
|
|
43
|
+
return session.effort.timeSpentMs > minEffortMs;
|
|
44
|
+
}
|
|
45
|
+
function formatTimeRemaining(session) {
|
|
46
|
+
const remaining = session.expiresAt - Date.now();
|
|
47
|
+
if (remaining <= 0) {
|
|
48
|
+
return "expired";
|
|
49
|
+
}
|
|
50
|
+
const hours = Math.floor(remaining / (60 * 60 * 1e3));
|
|
51
|
+
const days = Math.floor(hours / 24);
|
|
52
|
+
if (days > 0) {
|
|
53
|
+
return `${days} day${days > 1 ? "s" : ""}`;
|
|
54
|
+
}
|
|
55
|
+
if (hours > 0) {
|
|
56
|
+
return `${hours} hour${hours > 1 ? "s" : ""}`;
|
|
57
|
+
}
|
|
58
|
+
const minutes = Math.floor(remaining / (60 * 1e3));
|
|
59
|
+
return `${minutes} minute${minutes > 1 ? "s" : ""}`;
|
|
60
|
+
}
|
|
61
|
+
function formatEffort(session) {
|
|
62
|
+
const minutes = Math.floor(session.effort.timeSpentMs / 6e4);
|
|
63
|
+
if (minutes < 1) {
|
|
64
|
+
return "just started";
|
|
65
|
+
}
|
|
66
|
+
if (minutes < 60) {
|
|
67
|
+
return `${minutes} minute${minutes > 1 ? "s" : ""}`;
|
|
68
|
+
}
|
|
69
|
+
const hours = Math.floor(minutes / 60);
|
|
70
|
+
const remainingMinutes = minutes % 60;
|
|
71
|
+
if (remainingMinutes === 0) {
|
|
72
|
+
return `${hours} hour${hours > 1 ? "s" : ""}`;
|
|
73
|
+
}
|
|
74
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
calculateTTL,
|
|
78
|
+
formatEffort,
|
|
79
|
+
formatTimeRemaining,
|
|
80
|
+
isExpired,
|
|
81
|
+
isExpiringSoon,
|
|
82
|
+
shouldConfirmCancel,
|
|
83
|
+
shouldNudge
|
|
84
|
+
};
|
|
85
|
+
//# sourceMappingURL=ttl.js.map
|
package/dist/ttl.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ttl.ts"],"sourcesContent":["/**\n * @module ttl\n * @description Smart TTL (Time-To-Live) management for form sessions\n *\n * ## Design Philosophy\n *\n * Traditional form systems delete abandoned forms after a fixed time.\n * This fails users who invest significant effort:\n *\n * - User A: Opens form, immediately abandons → 24h retention is fine\n * - User B: Spends 2 hours filling complex form → 24h retention loses their work!\n *\n * ## Effort-Based TTL\n *\n * This module calculates TTL based on user effort:\n *\n * ```\n * TTL = clamp(minDays, effortDays, maxDays)\n *\n * where:\n * effortDays = minutesSpent * effortMultiplier\n * ```\n *\n * Default values:\n * - minDays: 14 (two weeks minimum)\n * - maxDays: 90 (three months maximum)\n * - effortMultiplier: 0.5 (10 minutes = 5 extra days)\n *\n * ## Examples\n *\n * | Time Spent | Extra Days | Total TTL |\n * |------------|------------|-----------|\n * | 0 min | 0 | 14 days |\n * | 10 min | 5 | 14 days |\n * | 30 min | 15 | 15 days |\n * | 2 hours | 60 | 60 days |\n * | 4 hours | 120 | 90 days |\n *\n * ## Nudge System\n *\n * The nudge system sends reminders for stale forms:\n *\n * 1. After 48 hours of inactivity (configurable)\n * 2. Maximum 3 nudges (configurable)\n * 3. At least 24 hours between nudges\n *\n * ## Expiration Warnings\n *\n * Before a session expires, we warn the user:\n *\n * - 24 hours before expiration\n * - \"Your form will expire in 1 day. Say 'resume' to keep working.\"\n *\n * This gives users a chance to save their work.\n */\n\nimport type { FormDefinition, FormSession } from \"./types.js\";\nimport { FORM_DEFINITION_DEFAULTS } from \"./types.js\";\n\n/**\n * Calculate TTL based on user effort.\n *\n * The more time a user spends on a form, the longer we keep it.\n * This prevents losing significant work while still cleaning up abandoned forms.\n *\n * WHY effort-based:\n * - Respects user investment\n * - Automatic cleanup of abandoned forms\n * - No manual expiration management needed\n *\n * @param session - Current session with effort tracking\n * @param form - Form definition with TTL configuration\n * @returns Expiration timestamp (milliseconds since epoch)\n */\nexport function calculateTTL(\n session: FormSession,\n form?: FormDefinition,\n): number {\n const config = form?.ttl || {};\n\n // Get configuration with defaults\n const minDays = config.minDays ?? FORM_DEFINITION_DEFAULTS.ttl.minDays;\n const maxDays = config.maxDays ?? FORM_DEFINITION_DEFAULTS.ttl.maxDays;\n const multiplier =\n config.effortMultiplier ?? FORM_DEFINITION_DEFAULTS.ttl.effortMultiplier;\n\n // Calculate effort in minutes\n const minutesSpent = session.effort.timeSpentMs / 60000;\n\n // Calculate TTL in days based on effort\n // WHY this formula: Simple linear scaling, easy to understand\n // Example: 10 min work with 0.5 multiplier = 5 extra days\n const effortDays = minutesSpent * multiplier;\n\n // Clamp to [minDays, maxDays]\n // WHY clamp: Prevents both too-short and too-long retention\n const ttlDays = Math.min(maxDays, Math.max(minDays, effortDays));\n\n // Return expiration timestamp\n // WHY from Date.now(): Session might be restored, recalculate from now\n return Date.now() + ttlDays * 24 * 60 * 60 * 1000;\n}\n\n/**\n * Check if session should be nudged.\n *\n * Nudges are gentle reminders for stashed or inactive forms.\n *\n * WHY nudge:\n * - Users forget about forms they started\n * - Gentle reminders increase completion\n * - But too many nudges are annoying\n *\n * @param session - Session to check\n * @param form - Form definition with nudge configuration\n * @returns true if a nudge should be sent\n */\nexport function shouldNudge(\n session: FormSession,\n form?: FormDefinition,\n): boolean {\n const nudgeConfig = form?.nudge;\n\n // Nudging disabled\n if (nudgeConfig?.enabled === false) {\n return false;\n }\n\n // Already at max nudges\n // WHY limit: Don't annoy users with endless reminders\n const maxNudges =\n nudgeConfig?.maxNudges ?? FORM_DEFINITION_DEFAULTS.nudge.maxNudges;\n if ((session.nudgeCount || 0) >= maxNudges) {\n return false;\n }\n\n // Check if enough time has passed since last interaction\n // WHY time check: Don't nudge active users\n const afterInactiveHours =\n nudgeConfig?.afterInactiveHours ??\n FORM_DEFINITION_DEFAULTS.nudge.afterInactiveHours;\n const inactiveMs = afterInactiveHours * 60 * 60 * 1000;\n\n const timeSinceInteraction = Date.now() - session.effort.lastInteractionAt;\n if (timeSinceInteraction < inactiveMs) {\n return false;\n }\n\n // Check if we already nudged recently (at least 24h between nudges)\n // WHY 24h minimum: Prevents daily spam, gives user time to respond\n if (session.lastNudgeAt) {\n const timeSinceNudge = Date.now() - session.lastNudgeAt;\n if (timeSinceNudge < 24 * 60 * 60 * 1000) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Check if session is expiring soon.\n *\n * Used to send expiration warnings before session is deleted.\n *\n * @param session - Session to check\n * @param withinMs - Time window in milliseconds\n * @returns true if session expires within the window\n */\nexport function isExpiringSoon(\n session: FormSession,\n withinMs: number,\n): boolean {\n return session.expiresAt - Date.now() < withinMs;\n}\n\n/**\n * Check if session has expired.\n *\n * @param session - Session to check\n * @returns true if session has passed its expiration time\n */\nexport function isExpired(session: FormSession): boolean {\n return session.expiresAt < Date.now();\n}\n\n/**\n * Check if we should confirm before canceling.\n *\n * High-effort sessions deserve a confirmation before abandonment.\n *\n * WHY confirm:\n * - Prevent accidental loss of significant work\n * - \"Are you sure?\" for forms user invested in\n *\n * @param session - Session to check\n * @returns true if cancel should require confirmation\n */\nexport function shouldConfirmCancel(session: FormSession): boolean {\n // 5 minutes is the threshold for \"significant effort\"\n // WHY 5 minutes: Enough time to have done real work\n const minEffortMs = 5 * 60 * 1000;\n return session.effort.timeSpentMs > minEffortMs;\n}\n\n/**\n * Format remaining time for user display.\n *\n * Produces human-readable strings like:\n * - \"14 days\"\n * - \"3 hours\"\n * - \"45 minutes\"\n * - \"expired\"\n *\n * @param session - Session to format\n * @returns Human-readable time remaining\n */\nexport function formatTimeRemaining(session: FormSession): string {\n const remaining = session.expiresAt - Date.now();\n\n if (remaining <= 0) {\n return \"expired\";\n }\n\n const hours = Math.floor(remaining / (60 * 60 * 1000));\n const days = Math.floor(hours / 24);\n\n // Show days if more than 24 hours\n if (days > 0) {\n return `${days} day${days > 1 ? \"s\" : \"\"}`;\n }\n\n // Show hours if more than 1 hour\n if (hours > 0) {\n return `${hours} hour${hours > 1 ? \"s\" : \"\"}`;\n }\n\n // Show minutes for less than 1 hour\n const minutes = Math.floor(remaining / (60 * 1000));\n return `${minutes} minute${minutes > 1 ? \"s\" : \"\"}`;\n}\n\n/**\n * Format effort for user display.\n *\n * Produces human-readable strings like:\n * - \"just started\"\n * - \"5 minutes\"\n * - \"2 hours\"\n * - \"1h 30m\"\n *\n * @param session - Session to format\n * @returns Human-readable effort description\n */\nexport function formatEffort(session: FormSession): string {\n const minutes = Math.floor(session.effort.timeSpentMs / 60000);\n\n if (minutes < 1) {\n return \"just started\";\n }\n\n if (minutes < 60) {\n return `${minutes} minute${minutes > 1 ? \"s\" : \"\"}`;\n }\n\n const hours = Math.floor(minutes / 60);\n const remainingMinutes = minutes % 60;\n\n if (remainingMinutes === 0) {\n return `${hours} hour${hours > 1 ? \"s\" : \"\"}`;\n }\n\n return `${hours}h ${remainingMinutes}m`;\n}\n"],"mappings":"AAyDA,SAAS,gCAAgC;AAiBlC,SAAS,aACd,SACA,MACQ;AACR,QAAM,SAAS,MAAM,OAAO,CAAC;AAG7B,QAAM,UAAU,OAAO,WAAW,yBAAyB,IAAI;AAC/D,QAAM,UAAU,OAAO,WAAW,yBAAyB,IAAI;AAC/D,QAAM,aACJ,OAAO,oBAAoB,yBAAyB,IAAI;AAG1D,QAAM,eAAe,QAAQ,OAAO,cAAc;AAKlD,QAAM,aAAa,eAAe;AAIlC,QAAM,UAAU,KAAK,IAAI,SAAS,KAAK,IAAI,SAAS,UAAU,CAAC;AAI/D,SAAO,KAAK,IAAI,IAAI,UAAU,KAAK,KAAK,KAAK;AAC/C;AAgBO,SAAS,YACd,SACA,MACS;AACT,QAAM,cAAc,MAAM;AAG1B,MAAI,aAAa,YAAY,OAAO;AAClC,WAAO;AAAA,EACT;AAIA,QAAM,YACJ,aAAa,aAAa,yBAAyB,MAAM;AAC3D,OAAK,QAAQ,cAAc,MAAM,WAAW;AAC1C,WAAO;AAAA,EACT;AAIA,QAAM,qBACJ,aAAa,sBACb,yBAAyB,MAAM;AACjC,QAAM,aAAa,qBAAqB,KAAK,KAAK;AAElD,QAAM,uBAAuB,KAAK,IAAI,IAAI,QAAQ,OAAO;AACzD,MAAI,uBAAuB,YAAY;AACrC,WAAO;AAAA,EACT;AAIA,MAAI,QAAQ,aAAa;AACvB,UAAM,iBAAiB,KAAK,IAAI,IAAI,QAAQ;AAC5C,QAAI,iBAAiB,KAAK,KAAK,KAAK,KAAM;AACxC,aAAO;AAAA,IACT;AAAA,EACF;AAEA,SAAO;AACT;AAWO,SAAS,eACd,SACA,UACS;AACT,SAAO,QAAQ,YAAY,KAAK,IAAI,IAAI;AAC1C;AAQO,SAAS,UAAU,SAA+B;AACvD,SAAO,QAAQ,YAAY,KAAK,IAAI;AACtC;AAcO,SAAS,oBAAoB,SAA+B;AAGjE,QAAM,cAAc,IAAI,KAAK;AAC7B,SAAO,QAAQ,OAAO,cAAc;AACtC;AAcO,SAAS,oBAAoB,SAA8B;AAChE,QAAM,YAAY,QAAQ,YAAY,KAAK,IAAI;AAE/C,MAAI,aAAa,GAAG;AAClB,WAAO;AAAA,EACT;AAEA,QAAM,QAAQ,KAAK,MAAM,aAAa,KAAK,KAAK,IAAK;AACrD,QAAM,OAAO,KAAK,MAAM,QAAQ,EAAE;AAGlC,MAAI,OAAO,GAAG;AACZ,WAAO,GAAG,IAAI,OAAO,OAAO,IAAI,MAAM,EAAE;AAAA,EAC1C;AAGA,MAAI,QAAQ,GAAG;AACb,WAAO,GAAG,KAAK,QAAQ,QAAQ,IAAI,MAAM,EAAE;AAAA,EAC7C;AAGA,QAAM,UAAU,KAAK,MAAM,aAAa,KAAK,IAAK;AAClD,SAAO,GAAG,OAAO,UAAU,UAAU,IAAI,MAAM,EAAE;AACnD;AAcO,SAAS,aAAa,SAA8B;AACzD,QAAM,UAAU,KAAK,MAAM,QAAQ,OAAO,cAAc,GAAK;AAE7D,MAAI,UAAU,GAAG;AACf,WAAO;AAAA,EACT;AAEA,MAAI,UAAU,IAAI;AAChB,WAAO,GAAG,OAAO,UAAU,UAAU,IAAI,MAAM,EAAE;AAAA,EACnD;AAEA,QAAM,QAAQ,KAAK,MAAM,UAAU,EAAE;AACrC,QAAM,mBAAmB,UAAU;AAEnC,MAAI,qBAAqB,GAAG;AAC1B,WAAO,GAAG,KAAK,QAAQ,QAAQ,IAAI,MAAM,EAAE;AAAA,EAC7C;AAEA,SAAO,GAAG,KAAK,KAAK,gBAAgB;AACtC;","names":[]}
|