Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 54b9c9d7fdc2b68029ce2e57
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
functions/lib/feedback.ts
// Logic shared by the web app and the public API: statuses, tags, sorting, search and ids.
export const STATUSES = ["new", "review", "planned", "progress", "done", "closed"] as const;
export type Status = (typeof STATUSES)[number];
export const STATUS_LABEL: Record<Status, string> = {
new: "New",
review: "Under review",
planned: "Planned",
progress: "In progress",
done: "Done",
closed: "Closed",
};
/** The roadmap's columns, left to right. */
export const ROADMAP: Status[] = ["review", "planned", "progress", "done"];
/** Statuses still in play: what a board shows by default. */
export const ACTIVE: Status[] = ["new", "review", "planned", "progress"];
export const LIMITS = {
boardName: 80,
intro: 1000,
tagName: 24,
tags: 20,
title: 140,
details: 5000,
name: 60,
comment: 3000,
entryTitle: 140,
entryBody: 10000,
entryPosts: 50,
};
export const SORTS = ["top", "trending", "new"] as const;
export type Sort = (typeof SORTS)[number];
export const SORT_LABEL: Record<Sort, string> = { top: "Top", trending: "Trending", new: "New" };
export interface Tag { id: string; name: string }
export interface Board { name: string; intro: string; tags: string; createdBy: string; createdAt: number }
export interface Post {
id: string;
title: string;
details: string;
tags: string;
status: Status;
statusAt: number;
votes: number;
comments: number;
lastComment?: string | null;
pinned: boolean;
hidden: boolean;
merged?: string | null;
name: string;
by: string;
createdAt: number;
}
export interface Comment { id: string; body: string; name: string; team: boolean; by: string; at: number }
export interface Entry { id: string; title: string; body: string; posts: string; at: number; by: string }
export const DEFAULT_TAGS = ["Idea", "Bug"];
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
export const isStatus = (s: unknown): s is Status => STATUSES.includes(s as Status);
export function parseTags(raw: string | null | undefined): Tag[] {
try {
const list = JSON.parse(raw || "[]");
return Array.isArray(list) ? list.filter((t) => t && typeof t.id === "string" && typeof t.name === "string") : [];
} catch {
return [];
}
}
export function parseIds(raw: string | null | undefined): string[] {
try {
const list = JSON.parse(raw || "[]");
return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
/** Tag names cleaned up: trimmed, one space between words, no duplicates, within limits. */
export function cleanTagNames(names: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of names) {
const name = String(raw ?? "").replace(/\s+/g, " ").trim().slice(0, LIMITS.tagName);
if (!name || seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
out.push(name);
}
return out.slice(0, LIMITS.tags);
}
/** "Trending": votes and comments, weighed by age, so a newer post gaining votes rises above an old favorite. */
export function trendingScore(p: Pick<Post, "votes" | "comments" | "createdAt">, now: number): number {
const hours = Math.max(0, now - p.createdAt) / 3600;
return (p.votes + p.comments / 2) / Math.pow(hours + 2, 1.4);
}
/** Sorts posts for a list: pinned ones first, then by the chosen order. */
export function sortPosts<T extends Post>(posts: T[], sort: Sort, now = Math.floor(Date.now() / 1000)): T[] {
const key = (p: T) =>
sort === "top" ? p.votes : sort === "new" ? p.createdAt : trendingScore(p, now);
return [...posts].sort((a, b) =>
Number(b.pinned) - Number(a.pinned) || key(b) - key(a) || b.votes - a.votes || b.createdAt - a.createdAt);
}
const words = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 1);
/** True when every word of the search appears in the post's title, details or tag names. */
export function matchesSearch(p: Pick<Post, "title" | "details">, q: string, tagNames = ""): boolean {
const want = words(q);
if (!want.length) return true;
const hay = words(`${p.title} ${p.details} ${tagNames}`).join(" ");
return want.every((w) => hay.includes(w));
}
const STOP = new Set(["the", "a", "an", "to", "for", "of", "and", "or", "in", "on", "is", "it", "be", "with", "add", "please", "can", "would", "should", "could", "we", "you", "my", "me", "i", "able", "allow", "support", "option", "feature"]);
/** Posts whose titles share meaningful words with a draft title, to suggest voting instead of posting a duplicate. */
export function similarPosts<T extends Post>(posts: T[], title: string, max = 3): T[] {
const mine = new Set(words(title).filter((w) => !STOP.has(w) && w.length > 2));
if (!mine.size) return [];
return posts
.map((p) => {
const theirs = new Set(words(p.title).filter((w) => !STOP.has(w) && w.length > 2));
let shared = 0;
for (const w of mine) if (theirs.has(w) || [...theirs].some((t) => t.startsWith(w) || w.startsWith(t))) shared++;
return { p, score: shared / Math.max(mine.size, 1) };
})
.filter((x) => x.score >= 0.5)
.sort((a, b) => b.score - a.score || b.p.votes - a.p.votes)
.slice(0, max)
.map((x) => x.p);
}
/** Posts a list shows: not merged away. Hidden ones only reach owners and their authors (the read rule decides). */
export const listed = <T extends Post>(posts: T[]) => posts.filter((p) => !p.merged);
Change history
Point store links to openapps.free
Release with preview
Retry release
Bind app agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync