@aglyn/aglyn 1.0.0-beta.277 → 1.0.0-beta.283
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/package.json +11 -11
- package/src/lib/app-utils/account-preferences.d.ts +146 -11
- package/src/lib/app-utils/account-preferences.js +266 -18
- package/src/lib/app-utils/account-preferences.js.map +1 -1
- package/src/lib/app-utils/activity-presenter.js +2 -26
- package/src/lib/app-utils/activity-presenter.js.map +1 -1
- package/src/lib/app-utils/activity-type-labels.d.ts +18 -0
- package/src/lib/app-utils/activity-type-labels.js +46 -0
- package/src/lib/app-utils/activity-type-labels.js.map +1 -0
- package/src/lib/app-utils/artifact-provenance.d.ts +3 -1
- package/src/lib/app-utils/artifact-provenance.js.map +1 -1
- package/src/lib/app-utils/bundle-intent.d.ts +37 -0
- package/src/lib/app-utils/bundle-intent.js +47 -3
- package/src/lib/app-utils/bundle-intent.js.map +1 -1
- package/src/lib/app-utils/compose-reusable-components.d.ts +17 -1
- package/src/lib/app-utils/compose-reusable-components.js +80 -5
- package/src/lib/app-utils/compose-reusable-components.js.map +1 -1
- package/src/lib/app-utils/docs-help-sections.generated.js +13 -1
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +23 -11
- package/src/lib/app-utils/docs-help.generated.js +64 -7
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +359 -51
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/error-beacon.d.ts +19 -0
- package/src/lib/app-utils/error-beacon.js +28 -0
- package/src/lib/app-utils/error-beacon.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +3 -2
- package/src/lib/app-utils/health-report.js +22 -2
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/native-nav.d.ts +39 -4
- package/src/lib/app-utils/native-nav.js +69 -3
- package/src/lib/app-utils/native-nav.js.map +1 -1
- package/src/lib/app-utils/native-themes.d.ts +28 -17
- package/src/lib/app-utils/native-themes.js +123 -45
- package/src/lib/app-utils/native-themes.js.map +1 -1
- package/src/lib/app-utils/operator-alerts.js +4 -2
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/org-permission-editor.d.ts +75 -0
- package/src/lib/app-utils/org-permission-editor.js +124 -0
- package/src/lib/app-utils/org-permission-editor.js.map +1 -0
- package/src/lib/app-utils/org-permissions.d.ts +36 -0
- package/src/lib/app-utils/org-permissions.js +78 -26
- package/src/lib/app-utils/org-permissions.js.map +1 -1
- package/src/lib/app-utils/org-role-grants.d.ts +59 -9
- package/src/lib/app-utils/org-role-grants.js +97 -10
- package/src/lib/app-utils/org-role-grants.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.d.ts +13 -0
- package/src/lib/app-utils/plan-entitlements.js +22 -0
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/release-flags.d.ts +52 -1
- package/src/lib/app-utils/release-flags.js +68 -2
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/reusable-component-keys.d.ts +13 -0
- package/src/lib/app-utils/reusable-component-keys.js +12 -0
- package/src/lib/app-utils/reusable-component-keys.js.map +1 -1
- package/src/lib/app-utils/screen-analytics-aggregate.d.ts +34 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js +37 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -1
- package/src/lib/app-utils/site-care.d.ts +35 -0
- package/src/lib/app-utils/site-care.js +48 -0
- package/src/lib/app-utils/site-care.js.map +1 -1
- package/src/lib/app-utils/site-font-pairings.d.ts +59 -0
- package/src/lib/app-utils/site-font-pairings.js +514 -0
- package/src/lib/app-utils/site-font-pairings.js.map +1 -0
- package/src/lib/app-utils/site-starter.d.ts +136 -0
- package/src/lib/app-utils/site-starter.js +116 -0
- package/src/lib/app-utils/site-starter.js.map +1 -0
- package/src/lib/app-utils/stock-photos/stock-photo-picker-server.d.ts +95 -0
- package/src/lib/app-utils/stock-photos/stock-photo-picker-server.js +333 -0
- package/src/lib/app-utils/stock-photos/stock-photo-picker-server.js.map +1 -0
- package/src/lib/app-utils/stock-photos/stock-photo-picker.d.ts +188 -0
- package/src/lib/app-utils/stock-photos/stock-photo-picker.js +193 -0
- package/src/lib/app-utils/stock-photos/stock-photo-picker.js.map +1 -0
- package/src/lib/plugin-manager/feature-plugins.d.ts +32 -0
- package/src/lib/plugin-manager/feature-plugins.js +12 -0
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +27 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +37 -0
- package/src/lib/plugin-manager/plugin-entitlements.js +84 -2
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-media-ingest.d.ts +15 -0
- package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -1
- package/src/lib/plugin-manager/plugin-permissions.d.ts +7 -0
- package/src/lib/plugin-manager/plugin-permissions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-product-catalog.d.ts +12 -0
- package/src/lib/plugin-manager/plugin-product-catalog.js.map +1 -1
- package/src/lib/plugin-manager/plugin-site-starters.d.ts +73 -0
- package/src/lib/plugin-manager/plugin-site-starters.js +30 -0
- package/src/lib/plugin-manager/plugin-site-starters.js.map +1 -0
- package/src/lib/plugin-manager/plugin-sitemap-sections.d.ts +8 -1
- package/src/lib/plugin-manager/plugin-sitemap-sections.js.map +1 -1
- package/src/lib/plugin-manager/stock-photo-provider.d.ts +12 -0
- package/src/lib/plugin-manager/stock-photo-provider.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-analytics-aggregate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Aggregation for the per-screen traffic table (AGL-1844): folds the AGL-151\n * screen day docs (`hosts/{id}/screenAnalytics/{screenId}:{day}`) a range\n * query returns into one row per screen. Pure — the component owns the reads,\n * this owns the arithmetic, and the spec owns this.\n */\n\n/** The fields this table reads off one screen day doc. */\nexport interface ScreenDayDocData {\n screenId?: unknown\n total?: unknown\n devices?: Record<string, unknown> | null\n /**\n * Referring host → visits (AGL-2341).\n *\n * /api/analytics/collect increments this on EVERY pageview, keyed per\n * referrer host — the widest-fanout field on the document. It was written\n * for every visitor on every plan and folded into nothing: the only reader\n * was the per-screen drilldown, so the one question a site owner asks first\n * — where is my traffic coming from — could be answered one screen at a\n * time and never across the site.\n *\n * Folded here rather than left out because the sibling `devices` map is\n * aggregated the same way and the silence read as an oversight. It costs\n * nothing at the gate either: this table and the drilldown are behind the\n * SAME `screenAnalytics` Pro+ entitlement, so nothing paid is being given\n * away by summing a field the paid panel already shows.\n */\n referrers?: Record<string, unknown> | null\n}\n\nexport interface ScreenTrafficRow {\n screenId: string\n total: number\n devices: Record<string, number>\n referrers: Record<string, number>\n}\n\n/**\n * One row per screen, pageviews and device splits summed across the range,\n * sorted by views descending. Docs without a usable `screenId` or a positive\n * `total` are dropped — a malformed doc must cost a row, never NaN in a\n * column.\n */\nexport function aggregateScreenDays(\n docs: readonly ScreenDayDocData[],\n): ScreenTrafficRow[] {\n const byScreen = new Map<string, ScreenTrafficRow>()\n for (const data of docs) {\n const screenId = typeof data.screenId === 'string' ? data.screenId : ''\n const total = Number(data.total ?? 0)\n if (!screenId || !Number.isFinite(total) || total <= 0) continue\n let row = byScreen.get(screenId)\n if (!row) {\n row = { screenId, total: 0, devices: {}, referrers: {} }\n byScreen.set(screenId, row)\n }\n row.total += total\n for (const [device, count] of Object.entries(data.devices ?? {})) {\n const n = Number(count ?? 0)\n if (Number.isFinite(n) && n > 0) {\n row.devices[device] = (row.devices[device] ?? 0) + n\n }\n }\n for (const [host, count] of Object.entries(data.referrers ?? {})) {\n const n = Number(count ?? 0)\n if (Number.isFinite(n) && n > 0) {\n row.referrers[host] = (row.referrers[host] ?? 0) + n\n }\n }\n }\n return [...byScreen.values()].sort((a, b) => b.total - a.total)\n}\n\n/** The device with the highest count, or empty when none recorded. */\nexport function topDevice(row: ScreenTrafficRow): string {\n const top = Object.entries(row.devices).sort(([, a], [, b]) => b - a)[0]\n return top?.[0] ?? ''\n}\n\n/**\n * The referring host that sent the most visits to this screen, or empty.\n *\n * Empty is the common answer and a true one: a direct visit records no\n * referrer at all, so a screen people reach by typing the address has an\n * empty map rather than a missing one. The column renders that as `--`, the\n * same as `topDevice`, instead of inventing a \"direct\" bucket the collector\n * never wrote.\n */\nexport function topReferrer(row: ScreenTrafficRow): string {\n const top = Object.entries(row.referrers).sort(([, a], [, b]) => b - a)[0]\n return top?.[0] ?? ''\n}\n\n/**\n * A referrer key as a person reads it: `google_com` → `google.com`.\n *\n * The collector keys `referrers` by the referring host with every `.` (and\n * the other characters a Firestore field path refuses) written as `_`, so a\n * stored key is never what the visitor's browser showed. Host names carry no\n * `_` of their own in practice, so turning each back into a dot restores the\n * address; an empty key stays empty for the caller's `--`.\n */\nexport function referrerLabel(key: string): string {\n return key.replace(/_/g, '.')\n}\n"],"names":["aggregateScreenDays","docs","byScreen","Map","data","screenId","total","Number","isFinite","row","get","devices","referrers","set","device","count","Object","entries","n","host","values","sort","a","b","topDevice","top","topReferrer","referrerLabel","key","replace"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;CAKC,GAED,wDAAwD,GA+BxD;;;;;CAKC,GACD,OAAO,SAASA,oBACdC,IAAiC;IAEjC,MAAMC,WAAW,IAAIC;IACrB,KAAK,MAAMC,QAAQH,KAAM;YAEFG,aAQwBA,eAMFA;QAf3C,MAAMC,WAAW,OAAOD,KAAKC,QAAQ,KAAK,WAAWD,KAAKC,QAAQ,GAAG;QACrE,MAAMC,QAAQC,QAAOH,cAAAA,KAAKE,KAAK,YAAVF,cAAc;QACnC,IAAI,CAACC,YAAY,CAACE,OAAOC,QAAQ,CAACF,UAAUA,SAAS,GAAG;QACxD,IAAIG,MAAMP,SAASQ,GAAG,CAACL;QACvB,IAAI,CAACI,KAAK;YACRA,MAAM;gBAAEJ;gBAAUC,OAAO;gBAAGK,SAAS,CAAC;gBAAGC,WAAW,CAAC;YAAE;YACvDV,SAASW,GAAG,CAACR,UAAUI;QACzB;QACAA,IAAIH,KAAK,IAAIA;QACb,KAAK,MAAM,CAACQ,QAAQC,MAAM,IAAIC,OAAOC,OAAO,EAACb,gBAAAA,KAAKO,OAAO,YAAZP,gBAAgB,CAAC,GAAI;YAChE,MAAMc,IAAIX,OAAOQ,gBAAAA,QAAS;YAC1B,IAAIR,OAAOC,QAAQ,CAACU,MAAMA,IAAI,GAAG;oBACRT;gBAAvBA,IAAIE,OAAO,CAACG,OAAO,GAAG,EAACL,sBAAAA,IAAIE,OAAO,CAACG,OAAO,YAAnBL,sBAAuB,KAAKS;YACrD;QACF;QACA,KAAK,MAAM,CAACC,MAAMJ,MAAM,IAAIC,OAAOC,OAAO,EAACb,kBAAAA,KAAKQ,SAAS,YAAdR,kBAAkB,CAAC,GAAI;YAChE,MAAMc,IAAIX,OAAOQ,gBAAAA,QAAS;YAC1B,IAAIR,OAAOC,QAAQ,CAACU,MAAMA,IAAI,GAAG;oBACRT;gBAAvBA,IAAIG,SAAS,CAACO,KAAK,GAAG,EAACV,sBAAAA,IAAIG,SAAS,CAACO,KAAK,YAAnBV,sBAAuB,KAAKS;YACrD;QACF;IACF;IACA,OAAO;WAAIhB,SAASkB,MAAM;KAAG,CAACC,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAEjB,KAAK,GAAGgB,EAAEhB,KAAK;AAChE;AAEA,oEAAoE,GACpE,OAAO,SAASkB,UAAUf,GAAqB;;IAC7C,MAAMgB,MAAMT,OAAOC,OAAO,CAACR,IAAIE,OAAO,EAAEU,IAAI,CAAC,CAAC,GAAGC,EAAE,EAAE,GAAGC,EAAE,GAAKA,IAAID,EAAE,CAAC,EAAE;IACxE,eAAOG,uBAAAA,GAAK,CAAC,EAAE,mBAAI;AACrB;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,YAAYjB,GAAqB;;IAC/C,MAAMgB,MAAMT,OAAOC,OAAO,CAACR,IAAIG,SAAS,EAAES,IAAI,CAAC,CAAC,GAAGC,EAAE,EAAE,GAAGC,EAAE,GAAKA,IAAID,EAAE,CAAC,EAAE;IAC1E,eAAOG,uBAAAA,GAAK,CAAC,EAAE,mBAAI;AACrB;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,cAAcC,GAAW;IACvC,OAAOA,IAAIC,OAAO,CAAC,MAAM;AAC3B"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-analytics-aggregate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Aggregation for the per-screen traffic table (AGL-1844): folds the AGL-151\n * screen day docs (`hosts/{id}/screenAnalytics/{screenId}:{day}`) a range\n * query returns into one row per screen. Pure — the component owns the reads,\n * this owns the arithmetic, and the spec owns this.\n */\n\n/** The fields this table reads off one screen day doc. */\nexport interface ScreenDayDocData {\n screenId?: unknown\n total?: unknown\n devices?: Record<string, unknown> | null\n /**\n * Referring host → visits (AGL-2341).\n *\n * /api/analytics/collect increments this on EVERY pageview, keyed per\n * referrer host — the widest-fanout field on the document. It was written\n * for every visitor on every plan and folded into nothing: the only reader\n * was the per-screen drilldown, so the one question a site owner asks first\n * — where is my traffic coming from — could be answered one screen at a\n * time and never across the site.\n *\n * Folded here rather than left out because the sibling `devices` map is\n * aggregated the same way and the silence read as an oversight. It costs\n * nothing at the gate either: this table and the drilldown are behind the\n * SAME `screenAnalytics` Pro+ entitlement, so nothing paid is being given\n * away by summing a field the paid panel already shows.\n */\n referrers?: Record<string, unknown> | null\n}\n\nexport interface ScreenTrafficRow {\n screenId: string\n total: number\n devices: Record<string, number>\n referrers: Record<string, number>\n}\n\n/**\n * One row per screen, pageviews and device splits summed across the range,\n * sorted by views descending. Docs without a usable `screenId` or a positive\n * `total` are dropped — a malformed doc must cost a row, never NaN in a\n * column.\n */\nexport function aggregateScreenDays(\n docs: readonly ScreenDayDocData[],\n): ScreenTrafficRow[] {\n const byScreen = new Map<string, ScreenTrafficRow>()\n for (const data of docs) {\n const screenId = typeof data.screenId === 'string' ? data.screenId : ''\n const total = Number(data.total ?? 0)\n if (!screenId || !Number.isFinite(total) || total <= 0) continue\n let row = byScreen.get(screenId)\n if (!row) {\n row = { screenId, total: 0, devices: {}, referrers: {} }\n byScreen.set(screenId, row)\n }\n row.total += total\n for (const [device, count] of Object.entries(data.devices ?? {})) {\n const n = Number(count ?? 0)\n if (Number.isFinite(n) && n > 0) {\n row.devices[device] = (row.devices[device] ?? 0) + n\n }\n }\n for (const [host, count] of Object.entries(data.referrers ?? {})) {\n const n = Number(count ?? 0)\n if (Number.isFinite(n) && n > 0) {\n row.referrers[host] = (row.referrers[host] ?? 0) + n\n }\n }\n }\n return [...byScreen.values()].sort((a, b) => b.total - a.total)\n}\n\n/** The device with the highest count, or empty when none recorded. */\nexport function topDevice(row: ScreenTrafficRow): string {\n const top = Object.entries(row.devices).sort(([, a], [, b]) => b - a)[0]\n return top?.[0] ?? ''\n}\n\n/**\n * The referring host that sent the most visits to this screen, or empty.\n *\n * Empty is the common answer and a true one: a direct visit records no\n * referrer at all, so a screen people reach by typing the address has an\n * empty map rather than a missing one. The column renders that as `--`, the\n * same as `topDevice`, instead of inventing a \"direct\" bucket the collector\n * never wrote.\n */\nexport function topReferrer(row: ScreenTrafficRow): string {\n const top = Object.entries(row.referrers).sort(([, a], [, b]) => b - a)[0]\n return top?.[0] ?? ''\n}\n\n/**\n * A referrer key as a person reads it: `google_com` → `google.com`.\n *\n * The collector keys `referrers` by the referring host with every `.` (and\n * the other characters a Firestore field path refuses) written as `_`, so a\n * stored key is never what the visitor's browser showed. Host names carry no\n * `_` of their own in practice, so turning each back into a dot restores the\n * address; an empty key stays empty for the caller's `--`.\n */\nexport function referrerLabel(key: string): string {\n return key.replace(/_/g, '.')\n}\n\n/** One UTC day of one page's traffic, as `hosts/{id}/screenAnalytics/{screenId}:{day}` holds it. */\nexport interface ScreenTrafficDay {\n total: number\n referrers: Record<string, number>\n devices: Record<string, number>\n /** Summed dwell across the day's samples (AGL-2182). */\n dwellMs: number\n /** How many visits reported a dwell: the divisor. */\n dwellSamples: number\n}\n\n/** A referrer and the visits it sent. */\nexport interface ScreenReferrerCount {\n referrer: string\n count: number\n}\n\n/** A page's traffic over its window, as its detail view's \"Page traffic\" card shows it. */\nexport interface ScreenTrafficSummary {\n total: number\n /** The mean dwell over the visits that reported one, or null when none did. */\n avgDwellMs: number | null\n /** `desktop 38% · mobile 58% · tablet 4%`; empty when no device was recorded. */\n deviceLine: string\n /** The five referrers that sent the most visits, most first (raw keys: `referrerLabel` names them). */\n topReferrers: ScreenReferrerCount[]\n}\n\n/**\n * The figures of a page's \"Page traffic\" card from its day documents, oldest first (AGL-4011). One function so the\n * console card and the native page detail compute the same numbers, held by the native function cases.\n *\n * The dwell is averaged over SAMPLES, not over views (AGL-2182): not every visit reports one (a killed tab sends\n * nothing), so dividing by views would report less time than any visit spent. The device line names desktop, mobile\n * and tablet in that order, each over every recorded device.\n */\nexport function summarizeScreenTraffic(days: readonly ScreenTrafficDay[]): ScreenTrafficSummary {\n const total = days.reduce((sum, day) => sum + day.total, 0)\n const dwellTotalMs = days.reduce((sum, day) => sum + day.dwellMs, 0)\n const dwellSamples = days.reduce((sum, day) => sum + day.dwellSamples, 0)\n const referrers: Record<string, number> = {}\n const devices: Record<string, number> = {}\n for (const day of days) {\n for (const [host, count] of Object.entries(day.referrers)) referrers[host] = (referrers[host] ?? 0) + count\n for (const [device, count] of Object.entries(day.devices)) devices[device] = (devices[device] ?? 0) + count\n }\n const deviceSum = Object.values(devices).reduce((sum, count) => sum + count, 0)\n const deviceLine = deviceSum\n ? ['desktop', 'mobile', 'tablet']\n .filter((device) => devices[device])\n .map((device) => `${device} ${Math.round(((devices[device] ?? 0) / deviceSum) * 100)}%`)\n .join(' · ')\n : ''\n return {\n total,\n avgDwellMs: dwellSamples > 0 ? dwellTotalMs / dwellSamples : null,\n deviceLine,\n topReferrers: Object.entries(referrers)\n .sort(([, a], [, b]) => b - a)\n .slice(0, 5)\n .map(([referrer, count]) => ({ referrer, count })),\n }\n}\n"],"names":["aggregateScreenDays","docs","byScreen","Map","data","screenId","total","Number","isFinite","row","get","devices","referrers","set","device","count","Object","entries","n","host","values","sort","a","b","topDevice","top","topReferrer","referrerLabel","key","replace","summarizeScreenTraffic","days","reduce","sum","day","dwellTotalMs","dwellMs","dwellSamples","deviceSum","deviceLine","filter","map","Math","round","join","avgDwellMs","topReferrers","slice","referrer"],"mappings":"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"}
|
|
@@ -289,3 +289,38 @@ export declare function isSiteCarePublishAction(action: unknown): boolean;
|
|
|
289
289
|
export declare function siteCarePublishSentence(publish: SiteCarePublishLink | null, formatDate: (ms: number) => string): string | null;
|
|
290
290
|
/** A date as the owner's emails and statement write it, in Site Care's zone. */
|
|
291
291
|
export declare function formatSiteCareDate(ms: number): string;
|
|
292
|
+
/**
|
|
293
|
+
* The subcollection of a Site Care ticket that only staff read (AGL-4031):
|
|
294
|
+
* `supportTickets/{id}/siteCareStaff/intake` holds the workspaces an emailed
|
|
295
|
+
* request's sender belongs to. A workspace's own reads and exports of its
|
|
296
|
+
* tickets leave it out.
|
|
297
|
+
*/
|
|
298
|
+
export declare const SITE_CARE_STAFF_COLLECTION = "siteCareStaff";
|
|
299
|
+
/**
|
|
300
|
+
* A Site Care workspace an emailed request could belong to (AGL-4031): the
|
|
301
|
+
* sender is a member of it (as `uid`) and it has Site Care. A sender in
|
|
302
|
+
* several such workspaces has every one recorded at intake, staff-only,
|
|
303
|
+
* because which workspaces a person belongs to is theirs, not the filed
|
|
304
|
+
* workspace's to read.
|
|
305
|
+
*/
|
|
306
|
+
export interface SiteCareWorkspaceCandidate {
|
|
307
|
+
orgId: string;
|
|
308
|
+
/** The workspace's name when the request came in. */
|
|
309
|
+
name: string;
|
|
310
|
+
/** The sender's account in that workspace. */
|
|
311
|
+
uid: string;
|
|
312
|
+
}
|
|
313
|
+
/** Recorded candidates read back, malformed entries dropped, one per workspace. */
|
|
314
|
+
export declare function readSiteCareCandidates(value: unknown): SiteCareWorkspaceCandidate[];
|
|
315
|
+
/** The candidates other than the workspace the request is on now. */
|
|
316
|
+
export declare function otherSiteCareCandidates(candidates: readonly SiteCareWorkspaceCandidate[], orgId: string): SiteCareWorkspaceCandidate[];
|
|
317
|
+
/** "Also a member of: Acme Builders, Acme Rentals". */
|
|
318
|
+
export declare function siteCareCandidatesSentence(others: readonly SiteCareWorkspaceCandidate[]): string | null;
|
|
319
|
+
/**
|
|
320
|
+
* Why a request may not move to another workspace, or null when it may.
|
|
321
|
+
* Only an open request with nothing billed against its workspace moves: a
|
|
322
|
+
* finished one has counted its minutes against that workspace's month, a
|
|
323
|
+
* charge was billed to its Stripe customer, and an owner's approval was
|
|
324
|
+
* given for it. The server enforces the same rules.
|
|
325
|
+
*/
|
|
326
|
+
export declare function siteCareMoveBlockedReason(request: Pick<SiteCareRequest, 'state' | 'charge' | 'chargeSeq' | 'approval'>, others: readonly SiteCareWorkspaceCandidate[]): string | null;
|
|
@@ -306,5 +306,53 @@ export const SITE_CARE_BODY_MAX = 5000;
|
|
|
306
306
|
timeZoneName: 'short'
|
|
307
307
|
});
|
|
308
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* The subcollection of a Site Care ticket that only staff read (AGL-4031):
|
|
311
|
+
* `supportTickets/{id}/siteCareStaff/intake` holds the workspaces an emailed
|
|
312
|
+
* request's sender belongs to. A workspace's own reads and exports of its
|
|
313
|
+
* tickets leave it out.
|
|
314
|
+
*/ export const SITE_CARE_STAFF_COLLECTION = 'siteCareStaff';
|
|
315
|
+
/** Recorded candidates read back, malformed entries dropped, one per workspace. */ export function readSiteCareCandidates(value) {
|
|
316
|
+
if (!Array.isArray(value)) return [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
const candidates = [];
|
|
319
|
+
for (const entry of value){
|
|
320
|
+
const orgId = typeof (entry == null ? void 0 : entry.orgId) === 'string' ? entry.orgId : '';
|
|
321
|
+
const uid = typeof (entry == null ? void 0 : entry.uid) === 'string' ? entry.uid : '';
|
|
322
|
+
if (!orgId || !uid || seen.has(orgId)) continue;
|
|
323
|
+
seen.add(orgId);
|
|
324
|
+
candidates.push({
|
|
325
|
+
orgId,
|
|
326
|
+
uid,
|
|
327
|
+
name: typeof entry.name === 'string' && entry.name ? entry.name : orgId
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return candidates;
|
|
331
|
+
}
|
|
332
|
+
/** The candidates other than the workspace the request is on now. */ export function otherSiteCareCandidates(candidates, orgId) {
|
|
333
|
+
return candidates.filter((candidate)=>candidate.orgId !== orgId);
|
|
334
|
+
}
|
|
335
|
+
/** "Also a member of: Acme Builders, Acme Rentals". */ export function siteCareCandidatesSentence(others) {
|
|
336
|
+
return others.length ? `Also a member of: ${others.map((candidate)=>candidate.name).join(', ')}` : null;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Why a request may not move to another workspace, or null when it may.
|
|
340
|
+
* Only an open request with nothing billed against its workspace moves: a
|
|
341
|
+
* finished one has counted its minutes against that workspace's month, a
|
|
342
|
+
* charge was billed to its Stripe customer, and an owner's approval was
|
|
343
|
+
* given for it. The server enforces the same rules.
|
|
344
|
+
*/ export function siteCareMoveBlockedReason(request, others) {
|
|
345
|
+
if (request.state === 'closed') {
|
|
346
|
+
return 'This request is finished, so it stays on the workspace its time counted against.';
|
|
347
|
+
}
|
|
348
|
+
if (request.charge || request.chargeSeq > 0) {
|
|
349
|
+
return 'Extra time has been charged on this request, so it stays on the workspace that was billed.';
|
|
350
|
+
}
|
|
351
|
+
if (request.approval) {
|
|
352
|
+
return 'An owner’s approval of extra time is recorded on this request, so it stays on that workspace.';
|
|
353
|
+
}
|
|
354
|
+
if (!others.length) return 'The sender is not a member of another workspace with Site Care.';
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
309
357
|
|
|
310
358
|
//# sourceMappingURL=site-care.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/site-care.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n SITE_CARE_EXTRA_HOUR_USD,\n SITE_CARE_INCLUDED_BLOCK_MINUTES,\n SITE_CARE_INCLUDED_MINUTES,\n} from './plan-entitlements'\nimport { operatorIdentity } from './operator-identity'\nimport { describeHours, type OwnerApprovalRecord } from './owner-approval-record'\n\n/**\n * SITE CARE REQUESTS: THE ARITHMETIC (AGL-3959).\n *\n * A Site Care owner asks for a change by email or from the console, and the\n * request is a support ticket tagged `siteCare`. Staff open it (a timer\n * starts), make the change, and close it with one number: the minutes. This\n * module is everything about that which needs no Firestore and no Stripe —\n * the month a request counts in, how its minutes are counted against the\n * included hour, what the close allows, what extra time bills, and the\n * statement the month adds up to. The routes, the staff panel, the owner's\n * Billing card and the reconciliation all read it, so they cannot disagree.\n *\n * ## The rules, from the Decision Log of 2026-10-19\n *\n * - The included hour is `SITE_CARE_INCLUDED_MINUTES` a month, no rollover,\n * plus any goodwill minutes staff add to that month. Each request counts\n * against it in `SITE_CARE_INCLUDED_BLOCK_MINUTES` blocks, rounded UP.\n * - A request's share of the hour is fixed when it closes, against what the\n * month's earlier requests left. Correcting one request's minutes later\n * recomputes that request alone, against the same remainder, so a fix\n * never moves a charge on a request nobody touched.\n * - Past the hour, extra time bills at `SITE_CARE_EXTRA_HOUR_USD` per\n * STARTED hour — whole hours, rounded up — and never less than the hours\n * the owner approved. With no approval on file, or more time than was\n * approved, the close is blocked: ask the owner first, or absorb it (no\n * charge, the minutes still count).\n */\n\n/** The `kind` a Site Care support ticket carries. */\nexport const SITE_CARE_TICKET_KIND = 'siteCare'\n\n/** Site Care's promise: done within two business days. */\nexport const SITE_CARE_RESPONSE_BUSINESS_DAYS = 2\n\n/** The zone a Site Care month is drawn in: where the work is done. */\nexport const SITE_CARE_TIME_ZONE = 'America/Chicago'\n\n/** The close's minutes are rounded up to this step. */\nexport const SITE_CARE_CLOSE_STEP_MINUTES = 5\n\n/** The most minutes one request may log. */\nexport const SITE_CARE_MAX_REQUEST_MINUTES = 60 * 40\n\n/**\n * The tax code the extra-time item is billed under: the code every Aglyn\n * product carries (the Decision Log of 2026-10-18), read off the Site Care\n * product, which is checked to carry it before anything is charged.\n */\nexport const SITE_CARE_EXTRA_TIME_TAX_CODE = 'txcd_10103001'\n\n/**\n * WHERE A CHANGE IS EMAILED (AGL-3997): the support address. Site Care has no\n * address of its own. An owner writes to the support address they already\n * know, and a message to it from a member of a workspace with Site Care files\n * itself as a request; every other message is ordinary support mail. The\n * support mailbox also delivers each message to `support@<inbound mail\n * domain>`, where the mail provider announces it, and every Site Care email\n * replies to `support+<requestId>@<inbound mail domain>`, so an answer joins\n * its own request.\n */\nexport const SITE_CARE_INBOUND_LOCAL_PART = 'support'\n\n/**\n * The operator's support address, from the operator identity: what an owner\n * is told to email a change to, and what the intake reads as one. `null` on\n * a deployment that publishes no support address, where Request a change on\n * a site's dashboard is the only way in.\n */\nexport function siteCareSupportAddress(): string | null {\n return operatorIdentity().supportEmail\n}\n\n/**\n * The Reply-To of a Site Care email: `support+<requestId>@<domain>` for an\n * email about one request, so the answer joins it; the support address for\n * one about no single request (a statement, a project), so the answer reaches\n * the support inbox like any other message to it.\n */\nexport function siteCareReplyAddress(\n requestId: string | null,\n domain: string,\n supportEmail: string | null = siteCareSupportAddress(),\n): string {\n if (requestId) return `${SITE_CARE_INBOUND_LOCAL_PART}+${requestId}@${domain}`\n return supportEmail ?? `${SITE_CARE_INBOUND_LOCAL_PART}@${domain}`\n}\n\n/** The longest title and message a request keeps. */\nexport const SITE_CARE_SUBJECT_MAX = 150\nexport const SITE_CARE_BODY_MAX = 5000\n\n/** Stripe's limit on an invoice item's description is generous; ours is not. */\nexport const SITE_CARE_LINE_DESCRIPTION_MAX = 480\n\n/** Where a request came from. */\nexport type SiteCareSource = 'email' | 'console'\n\n/**\n * Where an extra-time charge stands, as the platform recorded it.\n *\n * `creating` is written BEFORE the Stripe call and `pending` after it, so a\n * call that succeeded with no row behind it shows as `creating` with no item\n * id — which the reconciliation reports. Whether a `pending` item has been\n * invoiced yet is read from Stripe when it matters, never assumed.\n */\nexport type SiteCareChargeStatus =\n | 'creating'\n | 'pending'\n | 'failed'\n | 'voided'\n | 'credited'\n | 'refunded'\n\n/** Charges that still bill, or are about to. */\nexport const SITE_CARE_LIVE_CHARGE_STATUSES: readonly SiteCareChargeStatus[] = [\n 'creating',\n 'pending',\n]\n\nexport interface SiteCareCharge {\n status: SiteCareChargeStatus\n /** Whole hours billed. */\n hours: number\n /** `hours × rate`, in cents, before tax. */\n amountCents: number\n /** The invoice line description, exactly as it was sent. */\n description: string\n /** The key the create was sent under, so a retry makes nothing twice. */\n idempotencyKey: string\n invoiceItemId: string | null\n /** The invoice the item landed on, once read. */\n invoiceId: string | null\n creditNoteId: string | null\n refundId: string | null\n /** What Stripe said, when a call failed. */\n error: string | null\n updatedAtMs: number\n}\n\n/** The site publish that shipped the change, from the site's activity log. */\nexport interface SiteCarePublishLink {\n hostId: string\n activityId: string\n action: string\n targetName: string | null\n targetId: string | null\n atMs: number\n}\n\n/** The `siteCare` map on a Site Care ticket. */\nexport interface SiteCareRequest {\n source: SiteCareSource\n hostId: string | null\n requesterEmail: string | null\n state: 'open' | 'closed'\n /** The month the request counts in: opened, until it closes; then closed. */\n month: string\n timerStartedAtMs: number | null\n timerStartedByUid: string | null\n closedAtMs: number | null\n /** The one number staff entered at close, or the latest correction of it. */\n minutesLogged: number | null\n /** The timer's own minutes at close, unrounded, for the record. */\n timerMinutes: number | null\n countedMinutes: number\n includedMinutesUsed: number\n overMinutes: number\n billedHours: number\n absorbed: boolean\n approval: OwnerApprovalRecord | null\n charge: SiteCareCharge | null\n /** How many charges this request has ever started; keys each one. */\n chargeSeq: number\n publish: SiteCarePublishLink | null\n}\n\n/** Whether a value is a `YYYY-MM` month key. */\nexport function isSiteCareMonthKey(value: unknown): value is string {\n return typeof value === 'string' && /^\\d{4}-(0[1-9]|1[0-2])$/.test(value)\n}\n\n/** The month an instant falls in, in Site Care's zone: `2026-10`. */\nexport function siteCareMonthKey(ms: number): string {\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone: SITE_CARE_TIME_ZONE,\n year: 'numeric',\n month: '2-digit',\n }).formatToParts(new Date(ms))\n const year = parts.find((part) => part.type === 'year')?.value ?? '1970'\n const month = parts.find((part) => part.type === 'month')?.value ?? '01'\n return `${year}-${month}`\n}\n\n/** The month before a month key. */\nexport function previousSiteCareMonthKey(key: string): string {\n const [year, month] = key.split('-').map(Number)\n return month === 1 ? `${year - 1}-12` : `${year}-${String(month - 1).padStart(2, '0')}`\n}\n\n/** \"October 2026\". */\nexport function siteCareMonthLabel(key: string): string {\n if (!isSiteCareMonthKey(key)) return key\n const [year, month] = key.split('-').map(Number)\n return new Date(Date.UTC(year, month - 1, 15)).toLocaleString('en-US', {\n month: 'long',\n year: 'numeric',\n timeZone: 'UTC',\n })\n}\n\n/** The document id of an org's month: `{orgId}_{YYYY-MM}`, and the ticket field. */\nexport function siteCareOrgMonth(orgId: string, month: string): string {\n return `${orgId}_${month}`\n}\n\n/** \"45 min\", \"1 h\", \"1 h 15 min\". */\nexport function formatSiteCareMinutes(minutes: number): string {\n const total = Math.max(0, Math.round(Number(minutes) || 0))\n if (total < 60) return `${total} min`\n const hours = Math.floor(total / 60)\n const rest = total % 60\n return rest ? `${hours} h ${rest} min` : `${hours} h`\n}\n\n/** \"$79\", \"$158.00\" → always whole-dollar style when whole. */\nexport function formatSiteCareUsd(cents: number): string {\n const dollars = (Math.round(Number(cents) || 0) / 100)\n return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`\n}\n\n/**\n * The close's number from the timer: elapsed minutes rounded UP to the next\n * five, and never less than five — a request somebody opened and closed was\n * still a request worked.\n */\nexport function closeMinutesFromTimer(startedAtMs: number | null, nowMs: number): number {\n if (!startedAtMs || nowMs <= startedAtMs) return SITE_CARE_CLOSE_STEP_MINUTES\n const elapsed = (nowMs - startedAtMs) / 60_000\n const rounded = Math.ceil(elapsed / SITE_CARE_CLOSE_STEP_MINUTES) * SITE_CARE_CLOSE_STEP_MINUTES\n return Math.min(SITE_CARE_MAX_REQUEST_MINUTES, Math.max(SITE_CARE_CLOSE_STEP_MINUTES, rounded))\n}\n\n/** Staff's number read back, or null when it is not a usable count of minutes. */\nexport function readSiteCareMinutes(value: unknown): number | null {\n const minutes = Number(value)\n if (!Number.isInteger(minutes) || minutes < 1 || minutes > SITE_CARE_MAX_REQUEST_MINUTES) {\n return null\n }\n return minutes\n}\n\n/** Minutes as they count against the included hour: whole blocks, rounded up. */\nexport function countedSiteCareMinutes(minutes: number): number {\n const value = Math.max(0, Number(minutes) || 0)\n return Math.ceil(value / SITE_CARE_INCLUDED_BLOCK_MINUTES) * SITE_CARE_INCLUDED_BLOCK_MINUTES\n}\n\n/** The month's included minutes: the hour plus goodwill. */\nexport function siteCareIncludedPool(goodwillMinutes: number): number {\n return SITE_CARE_INCLUDED_MINUTES + Math.max(0, Math.floor(Number(goodwillMinutes) || 0))\n}\n\nexport interface SiteCareAllocation {\n countedMinutes: number\n includedMinutesUsed: number\n overMinutes: number\n /** Whole hours the over-time needs, rounded up; 0 when within the hour. */\n requiredHours: number\n /** Included minutes the month had left before this request. */\n remainingBefore: number\n}\n\n/**\n * One request's minutes against what the month's OTHER closed requests left\n * of the included pool.\n */\nexport function allocateSiteCareMinutes(input: {\n minutes: number\n otherIncludedUsed: number\n goodwillMinutes: number\n}): SiteCareAllocation {\n const countedMinutes = countedSiteCareMinutes(input.minutes)\n const remainingBefore = Math.max(\n 0,\n siteCareIncludedPool(input.goodwillMinutes) - Math.max(0, input.otherIncludedUsed),\n )\n const includedMinutesUsed = Math.min(countedMinutes, remainingBefore)\n const overMinutes = countedMinutes - includedMinutesUsed\n return {\n countedMinutes,\n includedMinutesUsed,\n overMinutes,\n requiredHours: overMinutes > 0 ? Math.ceil(overMinutes / 60) : 0,\n remainingBefore,\n }\n}\n\nexport type SiteCareCloseVerdict =\n | { ok: true; billedHours: number; absorbed: boolean }\n | {\n ok: false\n /** No approval on file, or one for fewer hours than the work took. */\n reason: 'no-approval' | 'approval-exceeded'\n requiredHours: number\n approvedHours: number | null\n }\n\n/**\n * What a close (or a correction) may do with a request's extra time.\n *\n * Within the hour nothing bills. Past it, `absorb` closes with no charge;\n * otherwise an approval must cover the whole hours needed, and the bill is\n * the larger of those and the approved hours — the approved amount is the\n * floor. Nothing bills without the owner's recorded yes.\n */\nexport function siteCareCloseVerdict(input: {\n requiredHours: number\n approval: Pick<OwnerApprovalRecord, 'hours'> | null\n absorb: boolean\n}): SiteCareCloseVerdict {\n if (input.requiredHours <= 0) return { ok: true, billedHours: 0, absorbed: false }\n if (input.absorb) return { ok: true, billedHours: 0, absorbed: true }\n if (!input.approval) {\n return {\n ok: false,\n reason: 'no-approval',\n requiredHours: input.requiredHours,\n approvedHours: null,\n }\n }\n if (input.approval.hours < input.requiredHours) {\n return {\n ok: false,\n reason: 'approval-exceeded',\n requiredHours: input.requiredHours,\n approvedHours: input.approval.hours,\n }\n }\n return {\n ok: true,\n billedHours: Math.max(input.requiredHours, input.approval.hours),\n absorbed: false,\n }\n}\n\n/** Extra time in cents, before tax. */\nexport function siteCareExtraTimeCents(hours: number): number {\n return Math.max(0, Math.floor(hours)) * SITE_CARE_EXTRA_HOUR_USD * 100\n}\n\n/** One request as the month's statement counts it. */\nexport interface SiteCareStatementRequest {\n requestId: string\n title: string\n state: 'open' | 'closed'\n closedAtMs: number | null\n minutesLogged: number | null\n countedMinutes: number\n includedMinutesUsed: number\n overMinutes: number\n billedHours: number\n absorbed: boolean\n chargeStatus: SiteCareChargeStatus | null\n chargeCents: number\n}\n\nexport interface SiteCareMonthStatement {\n month: string\n monthLabel: string\n includedMinutes: number\n goodwillMinutes: number\n poolMinutes: number\n requests: number\n openRequests: number\n minutesLogged: number\n countedMinutes: number\n includedMinutesUsed: number\n minutesLeft: number\n /** Hours on charges that still bill. */\n extraHoursBilled: number\n extraBilledCents: number\n /** Cents voided, credited or refunded. */\n extraReversedCents: number\n}\n\n/** A request's facts as the statement reads them, from the stored map. */\nexport function siteCareStatementRequest(\n requestId: string,\n title: string,\n request: Partial<SiteCareRequest> | null | undefined,\n): SiteCareStatementRequest {\n const charge = request?.charge ?? null\n return {\n requestId,\n title,\n state: request?.state === 'closed' ? 'closed' : 'open',\n closedAtMs: request?.closedAtMs ?? null,\n minutesLogged: request?.minutesLogged ?? null,\n countedMinutes: Number(request?.countedMinutes) || 0,\n includedMinutesUsed: Number(request?.includedMinutesUsed) || 0,\n overMinutes: Number(request?.overMinutes) || 0,\n billedHours: Number(request?.billedHours) || 0,\n absorbed: request?.absorbed === true,\n chargeStatus: charge?.status ?? null,\n chargeCents: Number(charge?.amountCents) || 0,\n }\n}\n\n/** The month, summed. */\nexport function siteCareMonthStatement(input: {\n month: string\n requests: readonly SiteCareStatementRequest[]\n goodwillMinutes: number\n}): SiteCareMonthStatement {\n const closed = input.requests.filter((request) => request.state === 'closed')\n const goodwillMinutes = Math.max(0, Math.floor(Number(input.goodwillMinutes) || 0))\n const poolMinutes = siteCareIncludedPool(goodwillMinutes)\n const includedMinutesUsed = closed.reduce((sum, r) => sum + r.includedMinutesUsed, 0)\n const live = closed.filter(\n (r) => r.chargeStatus !== null && SITE_CARE_LIVE_CHARGE_STATUSES.includes(r.chargeStatus),\n )\n const reversed = closed.filter(\n (r) => r.chargeStatus === 'voided' || r.chargeStatus === 'credited' || r.chargeStatus === 'refunded',\n )\n return {\n month: input.month,\n monthLabel: siteCareMonthLabel(input.month),\n includedMinutes: SITE_CARE_INCLUDED_MINUTES,\n goodwillMinutes,\n poolMinutes,\n requests: input.requests.length,\n openRequests: input.requests.length - closed.length,\n minutesLogged: closed.reduce((sum, r) => sum + (r.minutesLogged ?? 0), 0),\n countedMinutes: closed.reduce((sum, r) => sum + r.countedMinutes, 0),\n includedMinutesUsed,\n minutesLeft: Math.max(0, poolMinutes - includedMinutesUsed),\n extraHoursBilled: live.reduce((sum, r) => sum + r.billedHours, 0),\n extraBilledCents: live.reduce((sum, r) => sum + r.chargeCents, 0),\n extraReversedCents: reversed.reduce((sum, r) => sum + r.chargeCents, 0),\n }\n}\n\n/** The month's totals in one line, as the statement and the invoice line say them. */\nexport function siteCareMonthSentence(statement: SiteCareMonthStatement): string {\n const requests = `${statement.requests} ${statement.requests === 1 ? 'request' : 'requests'}`\n return (\n `${statement.monthLabel}: ${requests}, ${formatSiteCareMinutes(statement.minutesLogged)} of work, ` +\n `${statement.includedMinutesUsed} of ${statement.poolMinutes} included minutes used` +\n (statement.extraHoursBilled > 0\n ? `, ${describeHours(statement.extraHoursBilled)} of extra time billed`\n : '') +\n '.'\n )\n}\n\n/**\n * The extra-time invoice line's description: the request, its minutes, the\n * hours billed and why, and the month's statement — what the owner reads on\n * the invoice, and what the reconciliation compares Stripe's copy against.\n */\nexport function siteCareExtraTimeDescription(input: {\n title: string\n minutesLogged: number\n overMinutes: number\n billedHours: number\n approvedHours: number | null\n statement: SiteCareMonthStatement\n}): string {\n const title = input.title.length > 80 ? `${input.title.slice(0, 79)}…` : input.title\n const approved =\n input.approvedHours && input.approvedHours > Math.ceil(input.overMinutes / 60)\n ? ` (the ${describeHours(input.approvedHours)} you approved)`\n : ''\n const line =\n `Site Care extra time — “${title}”: ${formatSiteCareMinutes(input.minutesLogged)} of work, ` +\n `${formatSiteCareMinutes(input.overMinutes)} past the included hour, billed as ` +\n `${describeHours(input.billedHours)}${approved} at $${SITE_CARE_EXTRA_HOUR_USD}/hour. ` +\n siteCareMonthSentence(input.statement)\n return line.length > SITE_CARE_LINE_DESCRIPTION_MAX\n ? `${line.slice(0, SITE_CARE_LINE_DESCRIPTION_MAX - 1)}…`\n : line\n}\n\n/** A charge's standing in the owner's words. */\nexport function siteCareChargeWords(status: SiteCareChargeStatus | null): string {\n switch (status) {\n case 'creating':\n case 'pending':\n return 'On your next invoice'\n case 'failed':\n return 'Not billed'\n case 'voided':\n return 'Removed, not billed'\n case 'credited':\n return 'Credited back'\n case 'refunded':\n return 'Refunded'\n default:\n return 'Included'\n }\n}\n\n/** Whether a site activity row is a publish a request can be linked to. */\nexport function isSiteCarePublishAction(action: unknown): boolean {\n return typeof action === 'string' && /^published\\b/i.test(action.trim())\n}\n\n/** A publish link as a sentence: \"Published “Home” on Oct 19, 2026, 3:04 PM\". */\nexport function siteCarePublishSentence(\n publish: SiteCarePublishLink | null,\n formatDate: (ms: number) => string,\n): string | null {\n if (!publish) return null\n const what = publish.targetName ? ` “${publish.targetName}”` : ''\n return `${publish.action}${what} on ${formatDate(publish.atMs)}`\n}\n\n/** A date as the owner's emails and statement write it, in Site Care's zone. */\nexport function formatSiteCareDate(ms: number): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone: SITE_CARE_TIME_ZONE,\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n"],"names":["SITE_CARE_EXTRA_HOUR_USD","SITE_CARE_INCLUDED_BLOCK_MINUTES","SITE_CARE_INCLUDED_MINUTES","operatorIdentity","describeHours","SITE_CARE_TICKET_KIND","SITE_CARE_RESPONSE_BUSINESS_DAYS","SITE_CARE_TIME_ZONE","SITE_CARE_CLOSE_STEP_MINUTES","SITE_CARE_MAX_REQUEST_MINUTES","SITE_CARE_EXTRA_TIME_TAX_CODE","SITE_CARE_INBOUND_LOCAL_PART","siteCareSupportAddress","supportEmail","siteCareReplyAddress","requestId","domain","SITE_CARE_SUBJECT_MAX","SITE_CARE_BODY_MAX","SITE_CARE_LINE_DESCRIPTION_MAX","SITE_CARE_LIVE_CHARGE_STATUSES","isSiteCareMonthKey","value","test","siteCareMonthKey","ms","parts","Intl","DateTimeFormat","timeZone","year","month","formatToParts","Date","find","part","type","previousSiteCareMonthKey","key","split","map","Number","String","padStart","siteCareMonthLabel","UTC","toLocaleString","siteCareOrgMonth","orgId","formatSiteCareMinutes","minutes","total","Math","max","round","hours","floor","rest","formatSiteCareUsd","cents","dollars","isInteger","toFixed","closeMinutesFromTimer","startedAtMs","nowMs","elapsed","rounded","ceil","min","readSiteCareMinutes","countedSiteCareMinutes","siteCareIncludedPool","goodwillMinutes","allocateSiteCareMinutes","input","countedMinutes","remainingBefore","otherIncludedUsed","includedMinutesUsed","overMinutes","requiredHours","siteCareCloseVerdict","ok","billedHours","absorbed","absorb","approval","reason","approvedHours","siteCareExtraTimeCents","siteCareStatementRequest","title","request","charge","state","closedAtMs","minutesLogged","chargeStatus","status","chargeCents","amountCents","siteCareMonthStatement","closed","requests","filter","poolMinutes","reduce","sum","r","live","includes","reversed","monthLabel","includedMinutes","length","openRequests","minutesLeft","extraHoursBilled","extraBilledCents","extraReversedCents","siteCareMonthSentence","statement","siteCareExtraTimeDescription","slice","approved","line","siteCareChargeWords","isSiteCarePublishAction","action","trim","siteCarePublishSentence","publish","formatDate","what","targetName","atMs","formatSiteCareDate","day","hour","minute","timeZoneName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/site-care.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n SITE_CARE_EXTRA_HOUR_USD,\n SITE_CARE_INCLUDED_BLOCK_MINUTES,\n SITE_CARE_INCLUDED_MINUTES,\n} from './plan-entitlements'\nimport { operatorIdentity } from './operator-identity'\nimport { describeHours, type OwnerApprovalRecord } from './owner-approval-record'\n\n/**\n * SITE CARE REQUESTS: THE ARITHMETIC (AGL-3959).\n *\n * A Site Care owner asks for a change by email or from the console, and the\n * request is a support ticket tagged `siteCare`. Staff open it (a timer\n * starts), make the change, and close it with one number: the minutes. This\n * module is everything about that which needs no Firestore and no Stripe —\n * the month a request counts in, how its minutes are counted against the\n * included hour, what the close allows, what extra time bills, and the\n * statement the month adds up to. The routes, the staff panel, the owner's\n * Billing card and the reconciliation all read it, so they cannot disagree.\n *\n * ## The rules, from the Decision Log of 2026-10-19\n *\n * - The included hour is `SITE_CARE_INCLUDED_MINUTES` a month, no rollover,\n * plus any goodwill minutes staff add to that month. Each request counts\n * against it in `SITE_CARE_INCLUDED_BLOCK_MINUTES` blocks, rounded UP.\n * - A request's share of the hour is fixed when it closes, against what the\n * month's earlier requests left. Correcting one request's minutes later\n * recomputes that request alone, against the same remainder, so a fix\n * never moves a charge on a request nobody touched.\n * - Past the hour, extra time bills at `SITE_CARE_EXTRA_HOUR_USD` per\n * STARTED hour — whole hours, rounded up — and never less than the hours\n * the owner approved. With no approval on file, or more time than was\n * approved, the close is blocked: ask the owner first, or absorb it (no\n * charge, the minutes still count).\n */\n\n/** The `kind` a Site Care support ticket carries. */\nexport const SITE_CARE_TICKET_KIND = 'siteCare'\n\n/** Site Care's promise: done within two business days. */\nexport const SITE_CARE_RESPONSE_BUSINESS_DAYS = 2\n\n/** The zone a Site Care month is drawn in: where the work is done. */\nexport const SITE_CARE_TIME_ZONE = 'America/Chicago'\n\n/** The close's minutes are rounded up to this step. */\nexport const SITE_CARE_CLOSE_STEP_MINUTES = 5\n\n/** The most minutes one request may log. */\nexport const SITE_CARE_MAX_REQUEST_MINUTES = 60 * 40\n\n/**\n * The tax code the extra-time item is billed under: the code every Aglyn\n * product carries (the Decision Log of 2026-10-18), read off the Site Care\n * product, which is checked to carry it before anything is charged.\n */\nexport const SITE_CARE_EXTRA_TIME_TAX_CODE = 'txcd_10103001'\n\n/**\n * WHERE A CHANGE IS EMAILED (AGL-3997): the support address. Site Care has no\n * address of its own. An owner writes to the support address they already\n * know, and a message to it from a member of a workspace with Site Care files\n * itself as a request; every other message is ordinary support mail. The\n * support mailbox also delivers each message to `support@<inbound mail\n * domain>`, where the mail provider announces it, and every Site Care email\n * replies to `support+<requestId>@<inbound mail domain>`, so an answer joins\n * its own request.\n */\nexport const SITE_CARE_INBOUND_LOCAL_PART = 'support'\n\n/**\n * The operator's support address, from the operator identity: what an owner\n * is told to email a change to, and what the intake reads as one. `null` on\n * a deployment that publishes no support address, where Request a change on\n * a site's dashboard is the only way in.\n */\nexport function siteCareSupportAddress(): string | null {\n return operatorIdentity().supportEmail\n}\n\n/**\n * The Reply-To of a Site Care email: `support+<requestId>@<domain>` for an\n * email about one request, so the answer joins it; the support address for\n * one about no single request (a statement, a project), so the answer reaches\n * the support inbox like any other message to it.\n */\nexport function siteCareReplyAddress(\n requestId: string | null,\n domain: string,\n supportEmail: string | null = siteCareSupportAddress(),\n): string {\n if (requestId) return `${SITE_CARE_INBOUND_LOCAL_PART}+${requestId}@${domain}`\n return supportEmail ?? `${SITE_CARE_INBOUND_LOCAL_PART}@${domain}`\n}\n\n/** The longest title and message a request keeps. */\nexport const SITE_CARE_SUBJECT_MAX = 150\nexport const SITE_CARE_BODY_MAX = 5000\n\n/** Stripe's limit on an invoice item's description is generous; ours is not. */\nexport const SITE_CARE_LINE_DESCRIPTION_MAX = 480\n\n/** Where a request came from. */\nexport type SiteCareSource = 'email' | 'console'\n\n/**\n * Where an extra-time charge stands, as the platform recorded it.\n *\n * `creating` is written BEFORE the Stripe call and `pending` after it, so a\n * call that succeeded with no row behind it shows as `creating` with no item\n * id — which the reconciliation reports. Whether a `pending` item has been\n * invoiced yet is read from Stripe when it matters, never assumed.\n */\nexport type SiteCareChargeStatus =\n | 'creating'\n | 'pending'\n | 'failed'\n | 'voided'\n | 'credited'\n | 'refunded'\n\n/** Charges that still bill, or are about to. */\nexport const SITE_CARE_LIVE_CHARGE_STATUSES: readonly SiteCareChargeStatus[] = [\n 'creating',\n 'pending',\n]\n\nexport interface SiteCareCharge {\n status: SiteCareChargeStatus\n /** Whole hours billed. */\n hours: number\n /** `hours × rate`, in cents, before tax. */\n amountCents: number\n /** The invoice line description, exactly as it was sent. */\n description: string\n /** The key the create was sent under, so a retry makes nothing twice. */\n idempotencyKey: string\n invoiceItemId: string | null\n /** The invoice the item landed on, once read. */\n invoiceId: string | null\n creditNoteId: string | null\n refundId: string | null\n /** What Stripe said, when a call failed. */\n error: string | null\n updatedAtMs: number\n}\n\n/** The site publish that shipped the change, from the site's activity log. */\nexport interface SiteCarePublishLink {\n hostId: string\n activityId: string\n action: string\n targetName: string | null\n targetId: string | null\n atMs: number\n}\n\n/** The `siteCare` map on a Site Care ticket. */\nexport interface SiteCareRequest {\n source: SiteCareSource\n hostId: string | null\n requesterEmail: string | null\n state: 'open' | 'closed'\n /** The month the request counts in: opened, until it closes; then closed. */\n month: string\n timerStartedAtMs: number | null\n timerStartedByUid: string | null\n closedAtMs: number | null\n /** The one number staff entered at close, or the latest correction of it. */\n minutesLogged: number | null\n /** The timer's own minutes at close, unrounded, for the record. */\n timerMinutes: number | null\n countedMinutes: number\n includedMinutesUsed: number\n overMinutes: number\n billedHours: number\n absorbed: boolean\n approval: OwnerApprovalRecord | null\n charge: SiteCareCharge | null\n /** How many charges this request has ever started; keys each one. */\n chargeSeq: number\n publish: SiteCarePublishLink | null\n}\n\n/** Whether a value is a `YYYY-MM` month key. */\nexport function isSiteCareMonthKey(value: unknown): value is string {\n return typeof value === 'string' && /^\\d{4}-(0[1-9]|1[0-2])$/.test(value)\n}\n\n/** The month an instant falls in, in Site Care's zone: `2026-10`. */\nexport function siteCareMonthKey(ms: number): string {\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone: SITE_CARE_TIME_ZONE,\n year: 'numeric',\n month: '2-digit',\n }).formatToParts(new Date(ms))\n const year = parts.find((part) => part.type === 'year')?.value ?? '1970'\n const month = parts.find((part) => part.type === 'month')?.value ?? '01'\n return `${year}-${month}`\n}\n\n/** The month before a month key. */\nexport function previousSiteCareMonthKey(key: string): string {\n const [year, month] = key.split('-').map(Number)\n return month === 1 ? `${year - 1}-12` : `${year}-${String(month - 1).padStart(2, '0')}`\n}\n\n/** \"October 2026\". */\nexport function siteCareMonthLabel(key: string): string {\n if (!isSiteCareMonthKey(key)) return key\n const [year, month] = key.split('-').map(Number)\n return new Date(Date.UTC(year, month - 1, 15)).toLocaleString('en-US', {\n month: 'long',\n year: 'numeric',\n timeZone: 'UTC',\n })\n}\n\n/** The document id of an org's month: `{orgId}_{YYYY-MM}`, and the ticket field. */\nexport function siteCareOrgMonth(orgId: string, month: string): string {\n return `${orgId}_${month}`\n}\n\n/** \"45 min\", \"1 h\", \"1 h 15 min\". */\nexport function formatSiteCareMinutes(minutes: number): string {\n const total = Math.max(0, Math.round(Number(minutes) || 0))\n if (total < 60) return `${total} min`\n const hours = Math.floor(total / 60)\n const rest = total % 60\n return rest ? `${hours} h ${rest} min` : `${hours} h`\n}\n\n/** \"$79\", \"$158.00\" → always whole-dollar style when whole. */\nexport function formatSiteCareUsd(cents: number): string {\n const dollars = (Math.round(Number(cents) || 0) / 100)\n return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`\n}\n\n/**\n * The close's number from the timer: elapsed minutes rounded UP to the next\n * five, and never less than five — a request somebody opened and closed was\n * still a request worked.\n */\nexport function closeMinutesFromTimer(startedAtMs: number | null, nowMs: number): number {\n if (!startedAtMs || nowMs <= startedAtMs) return SITE_CARE_CLOSE_STEP_MINUTES\n const elapsed = (nowMs - startedAtMs) / 60_000\n const rounded = Math.ceil(elapsed / SITE_CARE_CLOSE_STEP_MINUTES) * SITE_CARE_CLOSE_STEP_MINUTES\n return Math.min(SITE_CARE_MAX_REQUEST_MINUTES, Math.max(SITE_CARE_CLOSE_STEP_MINUTES, rounded))\n}\n\n/** Staff's number read back, or null when it is not a usable count of minutes. */\nexport function readSiteCareMinutes(value: unknown): number | null {\n const minutes = Number(value)\n if (!Number.isInteger(minutes) || minutes < 1 || minutes > SITE_CARE_MAX_REQUEST_MINUTES) {\n return null\n }\n return minutes\n}\n\n/** Minutes as they count against the included hour: whole blocks, rounded up. */\nexport function countedSiteCareMinutes(minutes: number): number {\n const value = Math.max(0, Number(minutes) || 0)\n return Math.ceil(value / SITE_CARE_INCLUDED_BLOCK_MINUTES) * SITE_CARE_INCLUDED_BLOCK_MINUTES\n}\n\n/** The month's included minutes: the hour plus goodwill. */\nexport function siteCareIncludedPool(goodwillMinutes: number): number {\n return SITE_CARE_INCLUDED_MINUTES + Math.max(0, Math.floor(Number(goodwillMinutes) || 0))\n}\n\nexport interface SiteCareAllocation {\n countedMinutes: number\n includedMinutesUsed: number\n overMinutes: number\n /** Whole hours the over-time needs, rounded up; 0 when within the hour. */\n requiredHours: number\n /** Included minutes the month had left before this request. */\n remainingBefore: number\n}\n\n/**\n * One request's minutes against what the month's OTHER closed requests left\n * of the included pool.\n */\nexport function allocateSiteCareMinutes(input: {\n minutes: number\n otherIncludedUsed: number\n goodwillMinutes: number\n}): SiteCareAllocation {\n const countedMinutes = countedSiteCareMinutes(input.minutes)\n const remainingBefore = Math.max(\n 0,\n siteCareIncludedPool(input.goodwillMinutes) - Math.max(0, input.otherIncludedUsed),\n )\n const includedMinutesUsed = Math.min(countedMinutes, remainingBefore)\n const overMinutes = countedMinutes - includedMinutesUsed\n return {\n countedMinutes,\n includedMinutesUsed,\n overMinutes,\n requiredHours: overMinutes > 0 ? Math.ceil(overMinutes / 60) : 0,\n remainingBefore,\n }\n}\n\nexport type SiteCareCloseVerdict =\n | { ok: true; billedHours: number; absorbed: boolean }\n | {\n ok: false\n /** No approval on file, or one for fewer hours than the work took. */\n reason: 'no-approval' | 'approval-exceeded'\n requiredHours: number\n approvedHours: number | null\n }\n\n/**\n * What a close (or a correction) may do with a request's extra time.\n *\n * Within the hour nothing bills. Past it, `absorb` closes with no charge;\n * otherwise an approval must cover the whole hours needed, and the bill is\n * the larger of those and the approved hours — the approved amount is the\n * floor. Nothing bills without the owner's recorded yes.\n */\nexport function siteCareCloseVerdict(input: {\n requiredHours: number\n approval: Pick<OwnerApprovalRecord, 'hours'> | null\n absorb: boolean\n}): SiteCareCloseVerdict {\n if (input.requiredHours <= 0) return { ok: true, billedHours: 0, absorbed: false }\n if (input.absorb) return { ok: true, billedHours: 0, absorbed: true }\n if (!input.approval) {\n return {\n ok: false,\n reason: 'no-approval',\n requiredHours: input.requiredHours,\n approvedHours: null,\n }\n }\n if (input.approval.hours < input.requiredHours) {\n return {\n ok: false,\n reason: 'approval-exceeded',\n requiredHours: input.requiredHours,\n approvedHours: input.approval.hours,\n }\n }\n return {\n ok: true,\n billedHours: Math.max(input.requiredHours, input.approval.hours),\n absorbed: false,\n }\n}\n\n/** Extra time in cents, before tax. */\nexport function siteCareExtraTimeCents(hours: number): number {\n return Math.max(0, Math.floor(hours)) * SITE_CARE_EXTRA_HOUR_USD * 100\n}\n\n/** One request as the month's statement counts it. */\nexport interface SiteCareStatementRequest {\n requestId: string\n title: string\n state: 'open' | 'closed'\n closedAtMs: number | null\n minutesLogged: number | null\n countedMinutes: number\n includedMinutesUsed: number\n overMinutes: number\n billedHours: number\n absorbed: boolean\n chargeStatus: SiteCareChargeStatus | null\n chargeCents: number\n}\n\nexport interface SiteCareMonthStatement {\n month: string\n monthLabel: string\n includedMinutes: number\n goodwillMinutes: number\n poolMinutes: number\n requests: number\n openRequests: number\n minutesLogged: number\n countedMinutes: number\n includedMinutesUsed: number\n minutesLeft: number\n /** Hours on charges that still bill. */\n extraHoursBilled: number\n extraBilledCents: number\n /** Cents voided, credited or refunded. */\n extraReversedCents: number\n}\n\n/** A request's facts as the statement reads them, from the stored map. */\nexport function siteCareStatementRequest(\n requestId: string,\n title: string,\n request: Partial<SiteCareRequest> | null | undefined,\n): SiteCareStatementRequest {\n const charge = request?.charge ?? null\n return {\n requestId,\n title,\n state: request?.state === 'closed' ? 'closed' : 'open',\n closedAtMs: request?.closedAtMs ?? null,\n minutesLogged: request?.minutesLogged ?? null,\n countedMinutes: Number(request?.countedMinutes) || 0,\n includedMinutesUsed: Number(request?.includedMinutesUsed) || 0,\n overMinutes: Number(request?.overMinutes) || 0,\n billedHours: Number(request?.billedHours) || 0,\n absorbed: request?.absorbed === true,\n chargeStatus: charge?.status ?? null,\n chargeCents: Number(charge?.amountCents) || 0,\n }\n}\n\n/** The month, summed. */\nexport function siteCareMonthStatement(input: {\n month: string\n requests: readonly SiteCareStatementRequest[]\n goodwillMinutes: number\n}): SiteCareMonthStatement {\n const closed = input.requests.filter((request) => request.state === 'closed')\n const goodwillMinutes = Math.max(0, Math.floor(Number(input.goodwillMinutes) || 0))\n const poolMinutes = siteCareIncludedPool(goodwillMinutes)\n const includedMinutesUsed = closed.reduce((sum, r) => sum + r.includedMinutesUsed, 0)\n const live = closed.filter(\n (r) => r.chargeStatus !== null && SITE_CARE_LIVE_CHARGE_STATUSES.includes(r.chargeStatus),\n )\n const reversed = closed.filter(\n (r) => r.chargeStatus === 'voided' || r.chargeStatus === 'credited' || r.chargeStatus === 'refunded',\n )\n return {\n month: input.month,\n monthLabel: siteCareMonthLabel(input.month),\n includedMinutes: SITE_CARE_INCLUDED_MINUTES,\n goodwillMinutes,\n poolMinutes,\n requests: input.requests.length,\n openRequests: input.requests.length - closed.length,\n minutesLogged: closed.reduce((sum, r) => sum + (r.minutesLogged ?? 0), 0),\n countedMinutes: closed.reduce((sum, r) => sum + r.countedMinutes, 0),\n includedMinutesUsed,\n minutesLeft: Math.max(0, poolMinutes - includedMinutesUsed),\n extraHoursBilled: live.reduce((sum, r) => sum + r.billedHours, 0),\n extraBilledCents: live.reduce((sum, r) => sum + r.chargeCents, 0),\n extraReversedCents: reversed.reduce((sum, r) => sum + r.chargeCents, 0),\n }\n}\n\n/** The month's totals in one line, as the statement and the invoice line say them. */\nexport function siteCareMonthSentence(statement: SiteCareMonthStatement): string {\n const requests = `${statement.requests} ${statement.requests === 1 ? 'request' : 'requests'}`\n return (\n `${statement.monthLabel}: ${requests}, ${formatSiteCareMinutes(statement.minutesLogged)} of work, ` +\n `${statement.includedMinutesUsed} of ${statement.poolMinutes} included minutes used` +\n (statement.extraHoursBilled > 0\n ? `, ${describeHours(statement.extraHoursBilled)} of extra time billed`\n : '') +\n '.'\n )\n}\n\n/**\n * The extra-time invoice line's description: the request, its minutes, the\n * hours billed and why, and the month's statement — what the owner reads on\n * the invoice, and what the reconciliation compares Stripe's copy against.\n */\nexport function siteCareExtraTimeDescription(input: {\n title: string\n minutesLogged: number\n overMinutes: number\n billedHours: number\n approvedHours: number | null\n statement: SiteCareMonthStatement\n}): string {\n const title = input.title.length > 80 ? `${input.title.slice(0, 79)}…` : input.title\n const approved =\n input.approvedHours && input.approvedHours > Math.ceil(input.overMinutes / 60)\n ? ` (the ${describeHours(input.approvedHours)} you approved)`\n : ''\n const line =\n `Site Care extra time — “${title}”: ${formatSiteCareMinutes(input.minutesLogged)} of work, ` +\n `${formatSiteCareMinutes(input.overMinutes)} past the included hour, billed as ` +\n `${describeHours(input.billedHours)}${approved} at $${SITE_CARE_EXTRA_HOUR_USD}/hour. ` +\n siteCareMonthSentence(input.statement)\n return line.length > SITE_CARE_LINE_DESCRIPTION_MAX\n ? `${line.slice(0, SITE_CARE_LINE_DESCRIPTION_MAX - 1)}…`\n : line\n}\n\n/** A charge's standing in the owner's words. */\nexport function siteCareChargeWords(status: SiteCareChargeStatus | null): string {\n switch (status) {\n case 'creating':\n case 'pending':\n return 'On your next invoice'\n case 'failed':\n return 'Not billed'\n case 'voided':\n return 'Removed, not billed'\n case 'credited':\n return 'Credited back'\n case 'refunded':\n return 'Refunded'\n default:\n return 'Included'\n }\n}\n\n/** Whether a site activity row is a publish a request can be linked to. */\nexport function isSiteCarePublishAction(action: unknown): boolean {\n return typeof action === 'string' && /^published\\b/i.test(action.trim())\n}\n\n/** A publish link as a sentence: \"Published “Home” on Oct 19, 2026, 3:04 PM\". */\nexport function siteCarePublishSentence(\n publish: SiteCarePublishLink | null,\n formatDate: (ms: number) => string,\n): string | null {\n if (!publish) return null\n const what = publish.targetName ? ` “${publish.targetName}”` : ''\n return `${publish.action}${what} on ${formatDate(publish.atMs)}`\n}\n\n/** A date as the owner's emails and statement write it, in Site Care's zone. */\nexport function formatSiteCareDate(ms: number): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone: SITE_CARE_TIME_ZONE,\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n\n/**\n * The subcollection of a Site Care ticket that only staff read (AGL-4031):\n * `supportTickets/{id}/siteCareStaff/intake` holds the workspaces an emailed\n * request's sender belongs to. A workspace's own reads and exports of its\n * tickets leave it out.\n */\nexport const SITE_CARE_STAFF_COLLECTION = 'siteCareStaff'\n\n/**\n * A Site Care workspace an emailed request could belong to (AGL-4031): the\n * sender is a member of it (as `uid`) and it has Site Care. A sender in\n * several such workspaces has every one recorded at intake, staff-only,\n * because which workspaces a person belongs to is theirs, not the filed\n * workspace's to read.\n */\nexport interface SiteCareWorkspaceCandidate {\n orgId: string\n /** The workspace's name when the request came in. */\n name: string\n /** The sender's account in that workspace. */\n uid: string\n}\n\n/** Recorded candidates read back, malformed entries dropped, one per workspace. */\nexport function readSiteCareCandidates(value: unknown): SiteCareWorkspaceCandidate[] {\n if (!Array.isArray(value)) return []\n const seen = new Set<string>()\n const candidates: SiteCareWorkspaceCandidate[] = []\n for (const entry of value) {\n const orgId = typeof entry?.orgId === 'string' ? entry.orgId : ''\n const uid = typeof entry?.uid === 'string' ? entry.uid : ''\n if (!orgId || !uid || seen.has(orgId)) continue\n seen.add(orgId)\n candidates.push({ orgId, uid, name: typeof entry.name === 'string' && entry.name ? entry.name : orgId })\n }\n return candidates\n}\n\n/** The candidates other than the workspace the request is on now. */\nexport function otherSiteCareCandidates(\n candidates: readonly SiteCareWorkspaceCandidate[],\n orgId: string,\n): SiteCareWorkspaceCandidate[] {\n return candidates.filter((candidate) => candidate.orgId !== orgId)\n}\n\n/** \"Also a member of: Acme Builders, Acme Rentals\". */\nexport function siteCareCandidatesSentence(others: readonly SiteCareWorkspaceCandidate[]): string | null {\n return others.length ? `Also a member of: ${others.map((candidate) => candidate.name).join(', ')}` : null\n}\n\n/**\n * Why a request may not move to another workspace, or null when it may.\n * Only an open request with nothing billed against its workspace moves: a\n * finished one has counted its minutes against that workspace's month, a\n * charge was billed to its Stripe customer, and an owner's approval was\n * given for it. The server enforces the same rules.\n */\nexport function siteCareMoveBlockedReason(\n request: Pick<SiteCareRequest, 'state' | 'charge' | 'chargeSeq' | 'approval'>,\n others: readonly SiteCareWorkspaceCandidate[],\n): string | null {\n if (request.state === 'closed') {\n return 'This request is finished, so it stays on the workspace its time counted against.'\n }\n if (request.charge || request.chargeSeq > 0) {\n return 'Extra time has been charged on this request, so it stays on the workspace that was billed.'\n }\n if (request.approval) {\n return 'An owner’s approval of extra time is recorded on this request, so it stays on that workspace.'\n }\n if (!others.length) return 'The sender is not a member of another workspace with Site Care.'\n return null\n}\n"],"names":["SITE_CARE_EXTRA_HOUR_USD","SITE_CARE_INCLUDED_BLOCK_MINUTES","SITE_CARE_INCLUDED_MINUTES","operatorIdentity","describeHours","SITE_CARE_TICKET_KIND","SITE_CARE_RESPONSE_BUSINESS_DAYS","SITE_CARE_TIME_ZONE","SITE_CARE_CLOSE_STEP_MINUTES","SITE_CARE_MAX_REQUEST_MINUTES","SITE_CARE_EXTRA_TIME_TAX_CODE","SITE_CARE_INBOUND_LOCAL_PART","siteCareSupportAddress","supportEmail","siteCareReplyAddress","requestId","domain","SITE_CARE_SUBJECT_MAX","SITE_CARE_BODY_MAX","SITE_CARE_LINE_DESCRIPTION_MAX","SITE_CARE_LIVE_CHARGE_STATUSES","isSiteCareMonthKey","value","test","siteCareMonthKey","ms","parts","Intl","DateTimeFormat","timeZone","year","month","formatToParts","Date","find","part","type","previousSiteCareMonthKey","key","split","map","Number","String","padStart","siteCareMonthLabel","UTC","toLocaleString","siteCareOrgMonth","orgId","formatSiteCareMinutes","minutes","total","Math","max","round","hours","floor","rest","formatSiteCareUsd","cents","dollars","isInteger","toFixed","closeMinutesFromTimer","startedAtMs","nowMs","elapsed","rounded","ceil","min","readSiteCareMinutes","countedSiteCareMinutes","siteCareIncludedPool","goodwillMinutes","allocateSiteCareMinutes","input","countedMinutes","remainingBefore","otherIncludedUsed","includedMinutesUsed","overMinutes","requiredHours","siteCareCloseVerdict","ok","billedHours","absorbed","absorb","approval","reason","approvedHours","siteCareExtraTimeCents","siteCareStatementRequest","title","request","charge","state","closedAtMs","minutesLogged","chargeStatus","status","chargeCents","amountCents","siteCareMonthStatement","closed","requests","filter","poolMinutes","reduce","sum","r","live","includes","reversed","monthLabel","includedMinutes","length","openRequests","minutesLeft","extraHoursBilled","extraBilledCents","extraReversedCents","siteCareMonthSentence","statement","siteCareExtraTimeDescription","slice","approved","line","siteCareChargeWords","isSiteCarePublishAction","action","trim","siteCarePublishSentence","publish","formatDate","what","targetName","atMs","formatSiteCareDate","day","hour","minute","timeZoneName","SITE_CARE_STAFF_COLLECTION","readSiteCareCandidates","Array","isArray","seen","Set","candidates","entry","uid","has","add","push","name","otherSiteCareCandidates","candidate","siteCareCandidatesSentence","others","join","siteCareMoveBlockedReason","chargeSeq"],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The font pairings a site's look is chosen from (AGL-4049): a heading family
|
|
19
|
+
* over a body family, each with the weights it loads.
|
|
20
|
+
*
|
|
21
|
+
* The platform's, not one plugin's: the AI site start picks among them for a
|
|
22
|
+
* business's own look, and a site starter names the one it was designed in
|
|
23
|
+
* (`site-starter.ts`), so a starter and a generated site speak one list.
|
|
24
|
+
* Moved here from the AI plugin's `ai-site-kinds.ts`, which re-exports it
|
|
25
|
+
* under its old names.
|
|
26
|
+
*
|
|
27
|
+
* Pure data, safe for any surface to import.
|
|
28
|
+
*/
|
|
29
|
+
/** A font pairing a look may choose: a heading family over a body family. */
|
|
30
|
+
export interface SiteFontPairing {
|
|
31
|
+
id: string;
|
|
32
|
+
/** What the pairing reads like, as the model is told. */
|
|
33
|
+
feel: string;
|
|
34
|
+
heading: {
|
|
35
|
+
family: string;
|
|
36
|
+
category: 'sans-serif' | 'serif';
|
|
37
|
+
weight: number;
|
|
38
|
+
sub: number;
|
|
39
|
+
};
|
|
40
|
+
body: {
|
|
41
|
+
family: string;
|
|
42
|
+
category: 'sans-serif' | 'serif';
|
|
43
|
+
weights: number[];
|
|
44
|
+
};
|
|
45
|
+
/** Letter spacing the headings carry, in em. */
|
|
46
|
+
tracking?: number;
|
|
47
|
+
/** Headings set in capitals: a condensed display face. */
|
|
48
|
+
caps?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The pairings, every family from the Google Fonts catalog the fonts plugin
|
|
52
|
+
* serves (`google-fonts.catalog.json`) and every weight one that family
|
|
53
|
+
* has — the AI plugin's `ai-site-look.spec.ts` holds both. The tenant loads
|
|
54
|
+
* them the way it loads the starter theme's family, so nothing here adds a
|
|
55
|
+
* font host.
|
|
56
|
+
*/
|
|
57
|
+
export declare const SITE_FONT_PAIRINGS: readonly SiteFontPairing[];
|
|
58
|
+
/** A pairing by id, or `null`. */
|
|
59
|
+
export declare function siteFontPairing(id: unknown): SiteFontPairing | null;
|