DocDoeAI / src /lib /beta /betaFeedback.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
7.24 kB
/**
* Day 4 — local, device-only beta feedback.
*
* Lets a beta student tell the builder what confused them or what broke, stored
* only in localStorage. No backend, no external service. Deliberately collects
* NO contact/personal data: no name, email, phone, school, or location fields.
* The message is capped and trimmed; empty messages are rejected.
*/
import { getBetaSessionId, trackBetaEvent } from "./betaAnalytics";
export type BetaFeedbackKind =
| "helped_understand"
| "still_confusing"
| "wrong_or_doubtful"
| "too_long"
| "need_more_examples"
| "class_confusing"
| "bug"
| "content_issue"
| "ui_issue"
| "too_hard"
| "too_easy"
| "liked"
| "other";
export type BetaFeedbackRating = 1 | 2 | 3 | 4 | 5;
export type BetaFeedbackDevice = {
viewportWidth?: number;
viewportHeight?: number;
userAgent?: string;
};
export type BetaFeedbackItem = {
id: string;
createdAt: string;
sessionId: string;
kind: BetaFeedbackKind;
rating?: BetaFeedbackRating;
message: string;
route: string;
subject?: string;
chapterTitle?: string;
missionTitle?: string;
stepLabel?: string;
device?: BetaFeedbackDevice;
status: "new" | "reviewed";
};
export type BetaFeedbackContext = {
kind: BetaFeedbackKind;
rating?: BetaFeedbackRating;
message: string;
route?: string;
subject?: string;
chapterTitle?: string;
missionTitle?: string;
stepLabel?: string;
includeDevice?: boolean;
};
export type BetaFeedbackSubmitResult =
| { ok: true; item: BetaFeedbackItem }
| { ok: false; error: "empty_message" | "storage_unavailable" };
export type BetaFeedbackSummary = {
total: number;
newCount: number;
reviewedCount: number;
byKind: Array<{ kind: BetaFeedbackKind; count: number }>;
bugCount: number;
lastAt: string | null;
};
export const BETA_FEEDBACK_STORAGE_KEY = "docdoe:beta-feedback";
export const MAX_FEEDBACK_MESSAGE_LENGTH = 500;
const MAX_STORED_FEEDBACK = 200;
export const BETA_FEEDBACK_KIND_LABELS: Record<BetaFeedbackKind, string> = {
helped_understand: "This helped me understand",
still_confusing: "Still confusing",
wrong_or_doubtful: "Wrong or doubtful answer",
too_long: "Too long",
need_more_examples: "Need more examples",
class_confusing: "Class was confusing",
bug: "Something broke (bug)",
content_issue: "Content looks wrong",
ui_issue: "Layout / UI problem",
too_hard: "Too hard",
too_easy: "Too easy",
liked: "I liked this",
other: "Other",
};
function hasStorage(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function randomId(): string {
try {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
} catch {
// fall through
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function currentRoute(): string {
if (typeof window === "undefined") return "";
try {
return window.location?.pathname ?? "";
} catch {
return "";
}
}
function isFeedbackLike(value: unknown): value is BetaFeedbackItem {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
return typeof item.id === "string" && typeof item.kind === "string" && typeof item.message === "string";
}
export function loadBetaFeedback(): BetaFeedbackItem[] {
if (!hasStorage()) return [];
try {
const raw = window.localStorage.getItem(BETA_FEEDBACK_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isFeedbackLike);
} catch {
return [];
}
}
export function saveBetaFeedback(items: BetaFeedbackItem[]): void {
if (!hasStorage()) return;
try {
window.localStorage.setItem(BETA_FEEDBACK_STORAGE_KEY, JSON.stringify(items.slice(-MAX_STORED_FEEDBACK)));
} catch {
// best-effort
}
}
function minimalDevice(): BetaFeedbackDevice | undefined {
if (typeof window === "undefined") return undefined;
try {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
// Coarse UA only; no fingerprinting, no PII.
userAgent: (navigator?.userAgent ?? "").slice(0, 180),
};
} catch {
return undefined;
}
}
/**
* Validate + store one feedback item, and record analytics events
* (feedback_submitted, plus bug_report_submitted when kind === "bug").
*/
export function submitBetaFeedback(context: BetaFeedbackContext): BetaFeedbackSubmitResult {
const message = (context.message ?? "").trim().slice(0, MAX_FEEDBACK_MESSAGE_LENGTH);
if (!message) return { ok: false, error: "empty_message" };
if (!hasStorage()) return { ok: false, error: "storage_unavailable" };
const item: BetaFeedbackItem = {
id: randomId(),
createdAt: new Date().toISOString(),
sessionId: getBetaSessionId(),
kind: context.kind,
rating: context.rating,
message,
route: context.route ?? currentRoute(),
subject: context.subject,
chapterTitle: context.chapterTitle,
missionTitle: context.missionTitle,
stepLabel: context.stepLabel,
device: context.includeDevice === false ? undefined : minimalDevice(),
status: "new",
};
const items = [...loadBetaFeedback(), item];
saveBetaFeedback(items);
const analyticsContext = {
subject: context.subject,
chapterTitle: context.chapterTitle,
missionTitle: context.missionTitle,
stepLabel: context.stepLabel,
route: context.route,
};
trackBetaEvent({ name: "feedback_submitted", metadata: { kind: item.kind, rating: item.rating ?? null }, ...analyticsContext });
if (item.kind === "bug") {
trackBetaEvent({ name: "bug_report_submitted", metadata: { rating: item.rating ?? null }, ...analyticsContext });
}
if (item.kind === "still_confusing") {
trackBetaEvent({ name: "confusing_answer_reported", metadata: { kind: item.kind }, ...analyticsContext });
}
if (item.kind === "wrong_or_doubtful") {
trackBetaEvent({ name: "wrong_answer_reported", metadata: { kind: item.kind }, ...analyticsContext });
}
return { ok: true, item };
}
export function markBetaFeedbackReviewed(id: string): BetaFeedbackItem[] {
const items = loadBetaFeedback().map((item) => (item.id === id ? { ...item, status: "reviewed" as const } : item));
saveBetaFeedback(items);
return items;
}
export function clearBetaFeedback(): void {
if (!hasStorage()) return;
try {
window.localStorage.removeItem(BETA_FEEDBACK_STORAGE_KEY);
} catch {
// ignore
}
}
export function getBetaFeedbackSummary(items: BetaFeedbackItem[] = loadBetaFeedback()): BetaFeedbackSummary {
const byKind = new Map<BetaFeedbackKind, number>();
for (const item of items) byKind.set(item.kind, (byKind.get(item.kind) ?? 0) + 1);
return {
total: items.length,
newCount: items.filter((item) => item.status === "new").length,
reviewedCount: items.filter((item) => item.status === "reviewed").length,
byKind: Array.from(byKind.entries()).map(([kind, count]) => ({ kind, count })),
bugCount: items.filter((item) => item.kind === "bug").length,
lastAt: items.length > 0 ? items[items.length - 1].createdAt : null,
};
}