@taskless-app/shared 0.1.7 → 0.1.8

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.
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ /** Один символ ленты = 5 секунд звонка. Час — 720 символов, читается
3
+ * глазами прямо в psql. */
4
+ export declare const CALL_QUALITY_BIN_MS = 5000;
5
+ /** Алфавит ленты: символ на бин. */
6
+ export declare const CallQualityBin: {
7
+ /** Норма. */
8
+ readonly ok: "0";
9
+ /** Слабо (LiveKit ConnectionQuality.Poor). */
10
+ readonly weak: "1";
11
+ /** Обрыв (переподключение / ConnectionQuality.Lost). */
12
+ readonly lost: "2";
13
+ };
14
+ export type CallQualityBin = (typeof CallQualityBin)[keyof typeof CallQualityBin];
15
+ /** Потолок длины ленты — 12 часов. Звонков длиннее не бывает, а без потолка
16
+ * клиент может прислать строку на мегабайты. */
17
+ export declare const CALL_QUALITY_BINS_MAX: number;
18
+ /** Потолок счётчиков — максимум prisma `Int` (32 бита). Превышение это не
19
+ * «некрасивое число», а падение INSERT'а на уровне драйвера, поэтому режем
20
+ * валидацией, а не в рантайме. 2 147 483 647 мс ≈ 24 дня. */
21
+ export declare const CALL_QUALITY_INT_MAX = 2147483647;
22
+ /**
23
+ * Что клиент шлёт серверу: раз в 45 секунд накопительный итог с начала
24
+ * сессии, при выходе — финальный через `sendBeacon`. Итог НАКОПИТЕЛЬНЫЙ, а не
25
+ * дельта: на этом держится идемпотентность (сервер делает upsert по паре
26
+ * сессия+участник и берёт максимум по каждому счётчику, так что повторы,
27
+ * гонки и опоздавшие пакеты ничего не ломают).
28
+ *
29
+ * `identity` и `displayName` в теле намеренно НЕТ: кто прислал отчёт — сервер
30
+ * знает из авторизации, а верить присланному identity значит разрешить писать
31
+ * чужую строку качества.
32
+ */
33
+ export declare const CallQualityReport: z.ZodObject<{
34
+ /** Лента по 5-секундным бинам, символы из `CallQualityBin`. */
35
+ bins: z.ZodDefault<z.ZodString>;
36
+ /** Сколько раз соединение полностью рвалось (RoomEvent.Reconnecting). */
37
+ reconnects: z.ZodNumber;
38
+ /** Время без связи, мс. */
39
+ offlineMs: z.ZodNumber;
40
+ /** Время в плохом качестве (Poor), мс. */
41
+ poorMs: z.ZodNumber;
42
+ /** Время в звонке, мс — знаменатель. Считается монотонным таймером
43
+ * (`performance.now`), а не системными часами: перевод времени или сон
44
+ * ноутбука иначе дают отрицательные интервалы. */
45
+ inCallMs: z.ZodNumber;
46
+ }, "strip", z.ZodTypeAny, {
47
+ bins: string;
48
+ reconnects: number;
49
+ offlineMs: number;
50
+ poorMs: number;
51
+ inCallMs: number;
52
+ }, {
53
+ reconnects: number;
54
+ offlineMs: number;
55
+ poorMs: number;
56
+ inCallMs: number;
57
+ bins?: string | undefined;
58
+ }>;
59
+ export type CallQualityReport = z.infer<typeof CallQualityReport>;
60
+ export type CallQualityReportInput = z.input<typeof CallQualityReport>;
61
+ /** Счётчики, из которых считается оценка. Строка `CallParticipantQuality`
62
+ * подходит как есть — лишние поля не мешают. */
63
+ export interface CallQualityCounters {
64
+ reconnects: number;
65
+ offlineMs: number;
66
+ poorMs: number;
67
+ inCallMs: number;
68
+ }
69
+ /** excellent — «Отлично», unstable — «С перебоями», poor — «Плохо». */
70
+ export type CallQualityRating = "excellent" | "unstable" | "poor";
71
+ /**
72
+ * Пороги. Границы включающие ровно так, как в спеке:
73
+ * Отлично — ноль обрывов И доля плохого времени МЕНЬШЕ 5%
74
+ * С перебоями — 1–2 обрыва ИЛИ доля от 5% до 20% ВКЛЮЧИТЕЛЬНО
75
+ * Плохо — 3+ обрыва ИЛИ доля БОЛЬШЕ 20%
76
+ * То есть ровно 5% — уже «с перебоями», ровно 20% — ещё «с перебоями».
77
+ */
78
+ export declare const CALL_QUALITY_THRESHOLDS: {
79
+ /** С этого числа обрывов — «Плохо». */
80
+ readonly poorReconnects: 3;
81
+ /** С этой доли плохого времени — «С перебоями» (включительно). */
82
+ readonly unstableBadRatio: 0.05;
83
+ /** ВЫШЕ этой доли — «Плохо» (сама граница ещё «С перебоями»). */
84
+ readonly poorBadRatio: 0.2;
85
+ };
86
+ /**
87
+ * Доля времени в плохом качестве: (без связи + слабо) / время в звонке.
88
+ *
89
+ * Знаменатель ноль (сессия только началась, клиент упал сразу) → 0, а не
90
+ * NaN: иначе любое сравнение с порогом даёт false и оценка молча схлопывается
91
+ * в «Отлично». При нулевом времени оценку определяют только обрывы.
92
+ */
93
+ export declare function callQualityBadRatio(counters: CallQualityCounters): number;
94
+ /** Оценка связи одного участника. */
95
+ export declare function rateCallQuality(counters: CallQualityCounters): CallQualityRating;
@@ -0,0 +1,101 @@
1
+ // PRJ3-82 — качество связи участника в звонке: контракт отчёта и оценка.
2
+ //
3
+ // Спека: docs/superpowers/specs/2026-08-06-call-connection-quality-design.md
4
+ //
5
+ // Оценку НЕ храним в базе, а вычисляем из чисел вот этой функцией — одной на
6
+ // api и web, чтобы обе стороны показывали одно и то же слово, а пороги можно
7
+ // было двигать, не переписывая накопленное. Пороги вкусовые и будут ездить,
8
+ // поэтому лежат рядом константами и проверены тестами ровно на границах.
9
+ import { z } from "zod";
10
+ // ——— Лента событий ———————————————————————————————————————————————
11
+ /** Один символ ленты = 5 секунд звонка. Час — 720 символов, читается
12
+ * глазами прямо в psql. */
13
+ export const CALL_QUALITY_BIN_MS = 5_000;
14
+ /** Алфавит ленты: символ на бин. */
15
+ export const CallQualityBin = {
16
+ /** Норма. */
17
+ ok: "0",
18
+ /** Слабо (LiveKit ConnectionQuality.Poor). */
19
+ weak: "1",
20
+ /** Обрыв (переподключение / ConnectionQuality.Lost). */
21
+ lost: "2",
22
+ };
23
+ /** Проверка ленты собирается из самого алфавита, а не хардкодится второй раз:
24
+ * добавят четвёртое состояние — валидация поедет следом, а не отвергнет его
25
+ * молча. Символы — цифры, экранировать в классе нечего. */
26
+ const BINS_RE = new RegExp(`^[${Object.values(CallQualityBin).join("")}]*$`);
27
+ /** Потолок длины ленты — 12 часов. Звонков длиннее не бывает, а без потолка
28
+ * клиент может прислать строку на мегабайты. */
29
+ export const CALL_QUALITY_BINS_MAX = (12 * 60 * 60 * 1000) / CALL_QUALITY_BIN_MS; // 8640
30
+ // ——— Отчёт клиента ———————————————————————————————————————————————
31
+ /** Потолок счётчиков — максимум prisma `Int` (32 бита). Превышение это не
32
+ * «некрасивое число», а падение INSERT'а на уровне драйвера, поэтому режем
33
+ * валидацией, а не в рантайме. 2 147 483 647 мс ≈ 24 дня. */
34
+ export const CALL_QUALITY_INT_MAX = 2_147_483_647;
35
+ /**
36
+ * Что клиент шлёт серверу: раз в 45 секунд накопительный итог с начала
37
+ * сессии, при выходе — финальный через `sendBeacon`. Итог НАКОПИТЕЛЬНЫЙ, а не
38
+ * дельта: на этом держится идемпотентность (сервер делает upsert по паре
39
+ * сессия+участник и берёт максимум по каждому счётчику, так что повторы,
40
+ * гонки и опоздавшие пакеты ничего не ломают).
41
+ *
42
+ * `identity` и `displayName` в теле намеренно НЕТ: кто прислал отчёт — сервер
43
+ * знает из авторизации, а верить присланному identity значит разрешить писать
44
+ * чужую строку качества.
45
+ */
46
+ export const CallQualityReport = z.object({
47
+ /** Лента по 5-секундным бинам, символы из `CallQualityBin`. */
48
+ bins: z.string().regex(BINS_RE).max(CALL_QUALITY_BINS_MAX).default(""),
49
+ /** Сколько раз соединение полностью рвалось (RoomEvent.Reconnecting). */
50
+ reconnects: z.number().int().min(0).max(CALL_QUALITY_INT_MAX),
51
+ /** Время без связи, мс. */
52
+ offlineMs: z.number().int().min(0).max(CALL_QUALITY_INT_MAX),
53
+ /** Время в плохом качестве (Poor), мс. */
54
+ poorMs: z.number().int().min(0).max(CALL_QUALITY_INT_MAX),
55
+ /** Время в звонке, мс — знаменатель. Считается монотонным таймером
56
+ * (`performance.now`), а не системными часами: перевод времени или сон
57
+ * ноутбука иначе дают отрицательные интервалы. */
58
+ inCallMs: z.number().int().min(0).max(CALL_QUALITY_INT_MAX),
59
+ });
60
+ /**
61
+ * Пороги. Границы включающие ровно так, как в спеке:
62
+ * Отлично — ноль обрывов И доля плохого времени МЕНЬШЕ 5%
63
+ * С перебоями — 1–2 обрыва ИЛИ доля от 5% до 20% ВКЛЮЧИТЕЛЬНО
64
+ * Плохо — 3+ обрыва ИЛИ доля БОЛЬШЕ 20%
65
+ * То есть ровно 5% — уже «с перебоями», ровно 20% — ещё «с перебоями».
66
+ */
67
+ export const CALL_QUALITY_THRESHOLDS = {
68
+ /** С этого числа обрывов — «Плохо». */
69
+ poorReconnects: 3,
70
+ /** С этой доли плохого времени — «С перебоями» (включительно). */
71
+ unstableBadRatio: 0.05,
72
+ /** ВЫШЕ этой доли — «Плохо» (сама граница ещё «С перебоями»). */
73
+ poorBadRatio: 0.2,
74
+ };
75
+ /**
76
+ * Доля времени в плохом качестве: (без связи + слабо) / время в звонке.
77
+ *
78
+ * Знаменатель ноль (сессия только началась, клиент упал сразу) → 0, а не
79
+ * NaN: иначе любое сравнение с порогом даёт false и оценка молча схлопывается
80
+ * в «Отлично». При нулевом времени оценку определяют только обрывы.
81
+ */
82
+ export function callQualityBadRatio(counters) {
83
+ if (counters.inCallMs <= 0)
84
+ return 0;
85
+ const badMs = counters.offlineMs + counters.poorMs;
86
+ // Зажимаем в [0, 1]: доля больше единицы получается на рассинхроне счётчиков
87
+ // и ломает подпись «97% времени», а меньше нуля — на мусоре в данных.
88
+ return Math.min(1, Math.max(0, badMs / counters.inCallMs));
89
+ }
90
+ /** Оценка связи одного участника. */
91
+ export function rateCallQuality(counters) {
92
+ const { poorReconnects, unstableBadRatio, poorBadRatio } = CALL_QUALITY_THRESHOLDS;
93
+ const { reconnects } = counters;
94
+ const ratio = callQualityBadRatio(counters);
95
+ if (reconnects >= poorReconnects || ratio > poorBadRatio)
96
+ return "poor";
97
+ if (reconnects > 0 || ratio >= unstableBadRatio)
98
+ return "unstable";
99
+ return "excellent";
100
+ }
101
+ //# sourceMappingURL=callQuality.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"callQuality.js","sourceRoot":"","sources":["../src/callQuality.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -16,3 +16,4 @@ export * from "./chatMessageKinds.js";
16
16
  export * from "./userHue.js";
17
17
  export * from "./releaseSources.js";
18
18
  export * from "./designTokens.js";
19
+ export * from "./callQuality.js";
package/dist/index.js CHANGED
@@ -16,4 +16,5 @@ export * from "./chatMessageKinds.js";
16
16
  export * from "./userHue.js";
17
17
  export * from "./releaseSources.js";
18
18
  export * from "./designTokens.js";
19
+ export * from "./callQuality.js";
19
20
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,yBAAyB,CAAC;AACxC,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,yBAAyB,CAAC;AACxC,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taskless-app/shared",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Shared types, Zod schemas and design tokens for the Taskless API contract — consumed by @taskless/api, @taskless/web and the Taskless mobile app.",
6
6
  "license": "MIT",