Download src/lib/beta/betaFeedback.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 7.24 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/beta/betaFeedback.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/beta/betaFeedback.ts
-
curl -L -o betaFeedback.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/beta/betaFeedback.ts
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, | |
| }; | |
| } | |