Files
echoboard/packages/api/prisma/seed.ts

1299 lines
62 KiB
TypeScript

import { PrismaClient, PostType } from "@prisma/client";
import { createCipheriv, createHmac, randomBytes } from "node:crypto";
const prisma = new PrismaClient();
const MASTER_KEY = Buffer.from(process.env.APP_MASTER_KEY!, "hex");
const BLIND_KEY = Buffer.from(process.env.APP_BLIND_INDEX_KEY!, "hex");
function encrypt(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", MASTER_KEY, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, encrypted, tag]).toString("base64");
}
function blindIndex(value: string): string {
return createHmac("sha256", BLIND_KEY).update(value.toLowerCase()).digest("hex");
}
function daysAgo(n: number): Date {
return new Date(Date.now() - n * 86400000);
}
function hoursAgo(n: number): Date {
return new Date(Date.now() - n * 3600000);
}
async function main() {
console.log("Wiping existing content (keeping accounts)...");
// Wipe in dependency order
await prisma.editHistory.deleteMany();
await prisma.reaction.deleteMany();
await prisma.attachment.deleteMany();
await prisma.adminNote.deleteMany();
await prisma.adminResponse.deleteMany();
await prisma.notification.deleteMany();
await prisma.postMerge.deleteMany();
await prisma.postTag.deleteMany();
await prisma.comment.deleteMany();
await prisma.statusChange.deleteMany();
await prisma.vote.deleteMany();
await prisma.activityEvent.deleteMany();
await prisma.pushSubscription.deleteMany();
await prisma.boardStatus.deleteMany();
await prisma.boardTemplate.deleteMany();
await prisma.post.deleteMany();
await prisma.board.deleteMany();
await prisma.changelogEntry.deleteMany();
await prisma.tag.deleteMany();
await prisma.category.deleteMany();
await prisma.webhook.deleteMany();
console.log("Creating users...");
// Keep existing admin and create fresh users with encrypted names
const admin = await prisma.adminUser.findFirst();
if (!admin) throw new Error("No admin user found");
// We'll create 14 users: mix of cookie-only, cookie-with-name, passkey
const userDefs: { name: string | null; auth: "COOKIE" | "PASSKEY"; username?: string }[] = [
{ name: "Marcus", auth: "PASSKEY", username: "marcus" },
{ name: "Lina", auth: "PASSKEY", username: "lina_k" },
{ name: "Tom", auth: "COOKIE" },
{ name: "Priya", auth: "COOKIE" },
{ name: null, auth: "COOKIE" }, // anon1
{ name: "Jesse", auth: "PASSKEY", username: "jesse" },
{ name: "Noor", auth: "COOKIE" },
{ name: null, auth: "COOKIE" }, // anon2
{ name: "Seb", auth: "COOKIE" },
{ name: "Hana", auth: "PASSKEY", username: "hana_m" },
{ name: "Derek", auth: "COOKIE" },
{ name: null, auth: "COOKIE" }, // anon3
{ name: "Yuki", auth: "COOKIE" },
{ name: "Fran", auth: "PASSKEY", username: "fran" },
];
// Delete old seed users (keep admin-linked user)
const adminLinkedId = admin.linkedUserId;
await prisma.user.deleteMany({
where: { id: { not: adminLinkedId ?? "nope" } },
});
const users: { id: string; name: string | null }[] = [];
for (const def of userDefs) {
const u = await prisma.user.create({
data: {
authMethod: def.auth,
displayName: def.name ? encrypt(def.name) : null,
username: def.username ?? null,
usernameIdx: def.username ? blindIndex(def.username) : null,
tokenHash: randomBytes(32).toString("hex"),
},
});
users.push({ id: u.id, name: def.name });
}
// Helpers
const u = (name: string) => users.find((x) => x.name === name)!.id;
const anons = users.filter((x) => !x.name);
const anon = (i: number) => anons[i].id;
const adminUserId = adminLinkedId!;
const period = new Date().toISOString().slice(0, 7);
console.log("Creating categories and tags...");
const cats = await Promise.all([
prisma.category.create({ data: { name: "Bug", slug: "bug" } }),
prisma.category.create({ data: { name: "Performance", slug: "performance" } }),
prisma.category.create({ data: { name: "UI/UX", slug: "ui-ux" } }),
prisma.category.create({ data: { name: "Mobile", slug: "mobile" } }),
prisma.category.create({ data: { name: "Integrations", slug: "integrations" } }),
prisma.category.create({ data: { name: "Billing", slug: "billing" } }),
]);
const tags = await Promise.all([
prisma.tag.create({ data: { name: "high-priority", color: "#EF4444" } }),
prisma.tag.create({ data: { name: "quick-win", color: "#22C55E" } }),
prisma.tag.create({ data: { name: "needs-repro", color: "#F59E0B" } }),
prisma.tag.create({ data: { name: "wontfix", color: "#6B7280" } }),
prisma.tag.create({ data: { name: "duplicate", color: "#8B5CF6" } }),
prisma.tag.create({ data: { name: "v2", color: "#3B82F6" } }),
]);
const tagId = (n: string) => tags.find((t) => t.name === n)!.id;
const catSlug = (n: string) => cats.find((c) => c.slug === n)!.slug;
console.log("Creating boards...");
const boards = await Promise.all([
prisma.board.create({
data: {
slug: "cloudpush",
name: "Cloudpush",
description: "File sync and cloud storage",
iconName: "IconCloud",
iconColor: "#3B82F6",
voteBudget: 10,
voteBudgetReset: "monthly",
staleDays: 60,
},
}),
prisma.board.create({
data: {
slug: "mealwise",
name: "Mealwise",
description: "Meal planning and recipes",
iconName: "IconChefHat",
iconColor: "#F97316",
voteBudget: 8,
voteBudgetReset: "biweekly",
},
}),
prisma.board.create({
data: {
slug: "trackpad",
name: "Trackpad",
description: "Project management for small teams",
iconName: "IconChecklist",
iconColor: "#8B5CF6",
voteBudget: 12,
voteBudgetReset: "monthly",
},
}),
prisma.board.create({
data: {
slug: "pocketcast",
name: "Pocketcast",
description: "Podcast player and discovery",
iconName: "IconHeadphones",
iconColor: "#EC4899",
voteBudget: 10,
voteBudgetReset: "monthly",
},
}),
prisma.board.create({
data: {
slug: "greenline",
name: "Greenline",
description: "Public transit and commute planning",
iconName: "IconBus",
iconColor: "#10B981",
voteBudget: 15,
voteBudgetReset: "weekly",
},
}),
prisma.board.create({
data: {
slug: "inkwell",
name: "Inkwell",
description: "Writing and note-taking",
iconName: "IconPencil",
iconColor: "#F59E0B",
voteBudget: 10,
voteBudgetReset: "monthly",
allowMultiVote: true,
},
}),
]);
const board = (slug: string) => boards.find((b) => b.slug === slug)!;
// Helper to create a post with votes, comments, status changes, tags, activity events
interface PostDef {
board: string;
type: PostType;
title: string;
desc: Record<string, string>;
author: string;
status?: string;
statusReason?: string;
category?: string;
pinned?: boolean;
editLocked?: boolean;
votes?: { user: string; weight?: number; importance?: string }[];
tags?: string[];
statusChanges?: { from: string; to: string; reason?: string; daysAgo: number }[];
comments?: CommentDef[];
onBehalfOf?: string;
createdDaysAgo: number;
edited?: { prevTitle?: string; prevDesc?: Record<string, string>; daysAgo: number };
}
interface CommentDef {
body: string;
author: string;
isAdmin?: boolean;
daysAgo: number;
reactions?: { emoji: string; users: string[] }[];
replies?: CommentDef[];
edited?: { prevBody: string; daysAgo: number };
}
async function createPost(def: PostDef) {
const b = board(def.board);
const created = daysAgo(def.createdDaysAgo);
const post = await prisma.post.create({
data: {
type: def.type,
title: def.title,
description: def.desc,
status: def.status ?? "OPEN",
statusReason: def.statusReason,
category: def.category,
isPinned: def.pinned ?? false,
isEditLocked: def.editLocked ?? false,
onBehalfOf: def.onBehalfOf,
boardId: b.id,
authorId: def.author,
voteCount: 0,
lastActivityAt: created,
createdAt: created,
updatedAt: created,
},
});
// Votes
let voteCount = 0;
for (const v of def.votes ?? []) {
await prisma.vote.create({
data: {
postId: post.id,
voterId: v.user,
weight: v.weight ?? 1,
importance: v.importance,
budgetPeriod: period,
createdAt: daysAgo(def.createdDaysAgo - 1 + Math.random() * def.createdDaysAgo),
},
});
voteCount += v.weight ?? 1;
}
await prisma.post.update({ where: { id: post.id }, data: { voteCount } });
// Tags
for (const t of def.tags ?? []) {
await prisma.postTag.create({ data: { postId: post.id, tagId: tagId(t) } });
}
// Status changes
for (const sc of def.statusChanges ?? []) {
await prisma.statusChange.create({
data: {
postId: post.id,
fromStatus: sc.from,
toStatus: sc.to,
reason: sc.reason,
changedBy: admin.id,
createdAt: daysAgo(sc.daysAgo),
},
});
}
// Comments (recursive for replies)
async function createComment(cdef: CommentDef, parentId?: string) {
const c = await prisma.comment.create({
data: {
body: cdef.body,
postId: post.id,
authorId: cdef.author,
isAdmin: cdef.isAdmin ?? false,
adminUserId: cdef.isAdmin ? admin.id : undefined,
replyToId: parentId,
createdAt: daysAgo(cdef.daysAgo),
updatedAt: cdef.edited ? daysAgo(cdef.edited.daysAgo) : daysAgo(cdef.daysAgo),
},
});
if (cdef.edited) {
await prisma.editHistory.create({
data: {
commentId: c.id,
editedBy: cdef.author,
previousBody: cdef.edited.prevBody,
createdAt: daysAgo(cdef.edited.daysAgo),
},
});
}
for (const r of cdef.reactions ?? []) {
for (const uid of r.users) {
await prisma.reaction.create({
data: { emoji: r.emoji, commentId: c.id, userId: uid, createdAt: daysAgo(cdef.daysAgo - 0.5) },
});
}
}
for (const reply of cdef.replies ?? []) {
await createComment(reply, c.id);
}
return c;
}
for (const cdef of def.comments ?? []) {
await createComment(cdef);
}
// Update lastActivityAt to most recent comment
const latestComment = def.comments?.length ? daysAgo(Math.min(...def.comments.map((c) => c.daysAgo))) : created;
await prisma.post.update({ where: { id: post.id }, data: { lastActivityAt: latestComment, updatedAt: latestComment } });
// Post edit history
if (def.edited) {
await prisma.editHistory.create({
data: {
postId: post.id,
editedBy: def.author,
previousTitle: def.edited.prevTitle,
previousDescription: def.edited.prevDesc ?? undefined,
createdAt: daysAgo(def.edited.daysAgo),
},
});
}
// Activity event
await prisma.activityEvent.create({
data: {
type: "post_created",
boardId: b.id,
postId: post.id,
metadata: { title: post.title, type: post.type },
createdAt: created,
},
});
return post;
}
console.log("Creating posts for Cloudpush...");
// ═══════════════════════════════════════════
// CLOUDPUSH - File sync & cloud storage
// ═══════════════════════════════════════════
await createPost({
board: "cloudpush",
type: PostType.BUG_REPORT,
title: "Files keep re-uploading after I rename them",
desc: { stepsToReproduce: "Upload a file, wait for sync, rename it locally. The old name version re-appears on the server within a few minutes.", expectedBehavior: "Renaming should just update the filename, not trigger a re-upload.", actualBehavior: "Both the old and new name exist. If I delete the old one it comes back.", environment: "macOS 14.3, Cloudpush 2.8.1" },
author: u("Marcus"),
status: "IN_PROGRESS",
category: catSlug("bug"),
pinned: true,
tags: ["high-priority"],
votes: [
{ user: u("Lina"), importance: "critical" },
{ user: u("Tom") },
{ user: u("Priya"), importance: "important" },
{ user: anon(0) },
{ user: u("Jesse"), importance: "critical" },
{ user: u("Noor") },
{ user: u("Seb") },
{ user: u("Hana"), importance: "important" },
{ user: u("Derek") },
{ user: u("Yuki") },
],
statusChanges: [
{ from: "OPEN", to: "UNDER_REVIEW", daysAgo: 25 },
{ from: "UNDER_REVIEW", to: "IN_PROGRESS", reason: "Confirmed. The rename detection heuristic is comparing file hashes wrong on APFS volumes. Working on a fix.", daysAgo: 18 },
],
comments: [
{ body: "Same thing happening to me on Windows 11. Renamed a whole folder of project files and got duplicates of everything.", author: u("Tom"), daysAgo: 27, reactions: [{ emoji: "👍", users: [u("Marcus"), u("Priya")] }] },
{ body: "I keep getting duplicates too. I'm on a NAS and every file I rename shows up twice.", author: anon(1), daysAgo: 26 },
{ body: "This is the rename detection heuristic. It uses inode tracking on Linux/APFS but falls back to hash comparison when inodes change, and APFS reassigns inodes on rename. We have a fix in testing.", author: adminUserId, isAdmin: true, daysAgo: 22, reactions: [{ emoji: "🎉", users: [u("Marcus"), u("Tom"), u("Lina")] }] },
{ body: "Any ETA on the fix? I have a client project with 400+ files and I'm scared to rename anything right now.", author: u("Lina"), daysAgo: 19 },
{ body: "Should be in 2.8.3, targeting next week. If you want to test the beta you can grab it from the settings page under \"Release channel\".", author: adminUserId, isAdmin: true, daysAgo: 18, reactions: [{ emoji: "❤️", users: [u("Lina")] }] },
{ body: "Running the beta for 2 days now, no more ghost duplicates. Thanks!", author: u("Lina"), daysAgo: 15, reactions: [{ emoji: "🎉", users: [u("Marcus"), u("Jesse")] }] },
],
createdDaysAgo: 28,
});
await createPost({
board: "cloudpush",
type: PostType.FEATURE_REQUEST,
title: "Let me share a folder with an expiration date",
desc: { useCase: "I share project folders with freelancers and always forget to revoke access when the contract ends. Want to set a date and have it auto-expire.", proposedSolution: "Add a \"valid until\" field when creating share links for folders.", alternativesConsidered: "I could set a calendar reminder but that defeats the purpose of the tool doing it for me." },
author: u("Priya"),
status: "PLANNED",
category: catSlug("ui-ux"),
tags: ["v2"],
votes: [
{ user: u("Marcus"), importance: "important" },
{ user: u("Tom"), importance: "nice_to_have" },
{ user: u("Jesse") },
{ user: u("Noor"), importance: "important" },
{ user: u("Derek") },
{ user: anon(1) },
{ user: u("Fran") },
],
statusChanges: [
{ from: "OPEN", to: "PLANNED", reason: "Good idea, adding to the v2 sharing overhaul.", daysAgo: 10 },
],
comments: [
{ body: "yes!! I shared a folder with a contractor 6 months ago and just realized they still have access", author: u("Noor"), daysAgo: 18, reactions: [{ emoji: "😬", users: [u("Priya")] }] },
{ body: "Same situation here but with an ex-employee. Would be nice to see a list of all active shares and revoke from one place too.", author: anon(2), daysAgo: 16 },
{ body: "We are planning a bigger sharing rework for v2. Expiration links, password-protected folders, per-file permissions. This will be part of it.", author: adminUserId, isAdmin: true, daysAgo: 10 },
],
edited: { prevTitle: "Shared folder link expiration", daysAgo: 19 },
createdDaysAgo: 20,
});
await createPost({
board: "cloudpush",
type: PostType.BUG_REPORT,
title: "Sync icon stuck on \"syncing\" even though everything is up to date",
desc: { stepsToReproduce: "Open the app, let everything sync, then check the tray icon. It shows the spinning sync arrows forever.", expectedBehavior: "Should show a green checkmark after sync finishes.", actualBehavior: "Spinning arrows forever. Clicking \"sync now\" doesn't fix it. Restarting the app does, but it comes back.", environment: "Ubuntu 22.04, Cloudpush 2.7.4 AppImage" },
author: u("Jesse"),
status: "DONE",
category: catSlug("bug"),
tags: ["quick-win"],
votes: [
{ user: u("Marcus") },
{ user: u("Seb") },
{ user: u("Derek") },
{ user: anon(2) },
],
statusChanges: [
{ from: "OPEN", to: "IN_PROGRESS", daysAgo: 35 },
{ from: "IN_PROGRESS", to: "DONE", reason: "Fixed in 2.8.0. The issue was a race condition in the file watcher debounce timer on Linux.", daysAgo: 30 },
],
comments: [
{ body: "Can confirm on Fedora 39 too. It seems to happen more when I have a lot of small files syncing.", author: u("Seb"), daysAgo: 38 },
{ body: "Fixed in 2.8.0 - the file watcher on Linux was firing duplicate events and the debounce wasn't catching them all. Let us know if you still see it.", author: adminUserId, isAdmin: true, daysAgo: 30, reactions: [{ emoji: "👍", users: [u("Jesse"), u("Seb")] }] },
],
createdDaysAgo: 40,
});
await createPost({
board: "cloudpush",
type: PostType.FEATURE_REQUEST,
title: "Bandwidth throttle setting",
desc: { useCase: "My upload saturates my connection and makes video calls choppy. I want to cap Cloudpush to like 5 Mbps upload during work hours.", proposedSolution: "A slider or input field for max upload/download speed, maybe with a schedule so it can be unlimited at night." },
author: u("Tom"),
status: "OPEN",
votes: [
{ user: u("Priya") },
{ user: u("Noor") },
{ user: u("Yuki") },
],
comments: [
{ body: "I would love a schedule option for this. Full speed at night, throttled during working hours.", author: u("Priya"), daysAgo: 12 },
{ body: "10 Mbps cap would be enough for me. My ISP gives me 25 up and Cloudpush takes all of it during initial sync.", author: anon(0), daysAgo: 10 },
],
edited: { prevTitle: "Upload speed limit / bandwidth cap", prevDesc: { useCase: "Cloudpush uses all my upload bandwidth. I need a way to limit it." }, daysAgo: 13 },
createdDaysAgo: 14,
});
await createPost({
board: "cloudpush",
type: PostType.BUG_REPORT,
title: "Can't upload files with # in the filename",
desc: { stepsToReproduce: "Try to upload a file named \"meeting notes #3.pdf\"", expectedBehavior: "File uploads normally", actualBehavior: "Error message: \"Invalid filename\". The # character seems to be blocked.", environment: "Web interface, Chrome 121" },
author: anon(0),
status: "DONE",
category: catSlug("bug"),
statusChanges: [
{ from: "OPEN", to: "DONE", reason: "The web uploader was URL-encoding filenames and # was breaking the path parser. Patched.", daysAgo: 5 },
],
votes: [{ user: u("Tom") }, { user: u("Derek") }],
comments: [
{ body: "Also happens with & and probably other special characters", author: u("Derek"), daysAgo: 8 },
{ body: "Patched. We were passing the filename through the URL path instead of a header. Special characters should all work now.", author: adminUserId, isAdmin: true, daysAgo: 5 },
],
createdDaysAgo: 10,
});
console.log("Creating posts for Mealwise...");
// ═══════════════════════════════════════════
// MEALWISE - Meal planning & recipes
// ═══════════════════════════════════════════
await createPost({
board: "mealwise",
type: PostType.FEATURE_REQUEST,
title: "Shopping list should combine quantities across recipes",
desc: { useCase: "If two recipes both need onions, I want one line that says \"3 onions\" not two separate entries.", proposedSolution: "Group ingredients by name, sum up the amounts. Maybe keep the recipe source as a note so I know where each amount came from." },
author: u("Hana"),
status: "IN_PROGRESS",
pinned: true,
tags: ["high-priority"],
votes: [
{ user: u("Marcus"), importance: "critical" },
{ user: u("Lina"), importance: "important" },
{ user: u("Tom") },
{ user: u("Priya") },
{ user: u("Noor") },
{ user: u("Seb") },
{ user: anon(0) },
{ user: u("Yuki") },
{ user: u("Fran"), importance: "critical" },
{ user: u("Derek") },
{ user: u("Jesse") },
{ user: anon(1) },
],
statusChanges: [
{ from: "OPEN", to: "UNDER_REVIEW", daysAgo: 22 },
{ from: "UNDER_REVIEW", to: "IN_PROGRESS", reason: "The hard part is ingredient matching - \"1 onion\" and \"1 medium yellow onion\" need to merge. Working on a fuzzy matcher.", daysAgo: 12 },
],
comments: [
{ body: "This is the #1 thing keeping me from using the meal planning feature. I end up rewriting the shopping list by hand anyway.", author: u("Fran"), daysAgo: 23, reactions: [{ emoji: "👍", users: [u("Hana"), u("Marcus"), u("Noor")] }] },
{ body: "Tricky part here is matching - \"2 cloves garlic\" and \"1 head of garlic\" are different things. We're working on an ingredient parser that handles unit conversions too.", author: adminUserId, isAdmin: true, daysAgo: 12,
reactions: [{ emoji: "🎉", users: [u("Hana"), u("Fran")] }],
},
{ body: "I'd rather have imperfect merging that I can fix than no merging at all. Even just matching exact strings would cover 80% of my recipes.", author: u("Lina"), daysAgo: 10 },
{ body: "Fair point. We might ship exact matching first and improve it over time.", author: adminUserId, isAdmin: true, daysAgo: 9 },
{ body: "Would the combined list let you check items off as you shop? That's the other thing that's missing.", author: anon(1), daysAgo: 7 },
],
createdDaysAgo: 25,
});
await createPost({
board: "mealwise",
type: PostType.BUG_REPORT,
title: "Serving size multiplier breaks fractions",
desc: { stepsToReproduce: "Open any recipe, change servings from 4 to 6. Look at ingredient amounts.", expectedBehavior: "1/2 cup should become 3/4 cup", actualBehavior: "Shows \"0.75 cup\" which is technically right but nobody measures that way", environment: "iOS app 3.1.2" },
author: u("Yuki"),
status: "PLANNED",
category: catSlug("mobile"),
votes: [
{ user: u("Hana") },
{ user: u("Fran") },
{ user: u("Noor") },
{ user: u("Priya") },
],
statusChanges: [
{ from: "OPEN", to: "PLANNED", reason: "Need a fraction display library. Adding to the next mobile sprint.", daysAgo: 6 },
],
comments: [
{ body: "It also says things like \"1.3333 tablespoons\" which... no.", author: u("Noor"), daysAgo: 11, reactions: [{ emoji: "😂", users: [u("Yuki"), u("Hana"), u("Fran")] }] },
{ body: "yeah we need proper fraction rounding. 1.33 tbsp should probably just say \"1 and 1/3 tablespoons\" or round to 1.5. Putting this on the sprint board.", author: adminUserId, isAdmin: true, daysAgo: 6 },
],
createdDaysAgo: 14,
});
await createPost({
board: "mealwise",
type: PostType.FEATURE_REQUEST,
title: "Import recipes from a URL",
desc: { useCase: "I find recipes on food blogs and want to save them to Mealwise without retyping everything. Most recipe sites use structured data (JSON-LD) so it should be parseable.", proposedSolution: "Paste a URL, the app scrapes the recipe schema and fills in the fields." },
author: u("Lina"),
status: "DONE",
tags: ["quick-win"],
votes: [
{ user: u("Hana") },
{ user: u("Priya") },
{ user: u("Marcus") },
{ user: u("Tom") },
{ user: u("Yuki") },
{ user: u("Fran") },
{ user: u("Jesse") },
{ user: anon(2) },
],
statusChanges: [
{ from: "OPEN", to: "IN_PROGRESS", daysAgo: 50 },
{ from: "IN_PROGRESS", to: "DONE", reason: "Shipped in 3.0. We parse JSON-LD, Microdata, and fall back to OpenGraph if neither is present.", daysAgo: 35 },
],
comments: [
{ body: "Paprika does this and it works for like 90% of sites. The ones that don't work are usually behind paywalls anyway.", author: u("Marcus"), daysAgo: 55 },
{ body: "Shipped in 3.0! Paste a URL and we'll pull the recipe automatically. Works with any site that uses JSON-LD or Microdata recipe markup, which is most of them.", author: adminUserId, isAdmin: true, daysAgo: 35, reactions: [{ emoji: "🎉", users: [u("Lina"), u("Marcus"), u("Hana"), u("Priya")] }] },
{ body: "Just tried it with a NYT Cooking link and it worked perfectly. Even got the photo.", author: u("Priya"), daysAgo: 33 },
],
createdDaysAgo: 60,
});
await createPost({
board: "mealwise",
type: PostType.FEATURE_REQUEST,
title: "Calorie and macro tracking per meal",
desc: { useCase: "I track my macros and currently have to manually enter everything into a separate app. Would be great if Mealwise showed protein/carbs/fat per serving.", proposedSolution: "Pull nutrition data from a database (USDA maybe?) and calculate per-recipe." },
author: u("Jesse"),
status: "OPEN",
votes: [
{ user: u("Seb") },
{ user: u("Hana") },
{ user: u("Derek") },
],
comments: [
{
body: "I'd use this but please make it optional. I don't want calorie counts in my face every time I open a recipe.",
author: u("Hana"), daysAgo: 4,
edited: { prevBody: "I'd use this but please don't make it the default. I don't want calorie counts everywhere.", daysAgo: 4 },
reactions: [{ emoji: "👍", users: [u("Noor"), u("Fran")] }],
replies: [
{ body: "Agreed. Maybe a toggle in settings, off by default?", author: u("Noor"), daysAgo: 3 },
],
},
],
createdDaysAgo: 7,
});
await createPost({
board: "mealwise",
type: PostType.BUG_REPORT,
title: "Timer alarm doesn't play when phone is on silent",
desc: { stepsToReproduce: "Set a cooking timer, put phone on silent mode, wait for timer to finish.", expectedBehavior: "Some kind of notification, vibration, anything.", actualBehavior: "Nothing happens. I burned the garlic bread.", environment: "iPhone 15, iOS 17.2, Mealwise 3.1.0" },
author: u("Tom"),
status: "OPEN",
category: catSlug("mobile"),
tags: ["needs-repro"],
votes: [{ user: u("Priya") }, { user: u("Noor") }],
comments: [
{ body: "Pretty sure this is an iOS restriction? Apps can't override silent mode unless they use a specific audio category. Maybe use the \"alarm\" audio session?", author: u("Seb"), daysAgo: 2 },
{ body: "Happened to me with cookies in the oven. A vibration at least would help.", author: anon(0), daysAgo: 1 },
],
createdDaysAgo: 4,
});
console.log("Creating posts for Trackpad...");
// ═══════════════════════════════════════════
// TRACKPAD - Project management
// ═══════════════════════════════════════════
await createPost({
board: "trackpad",
type: PostType.FEATURE_REQUEST,
title: "Recurring tasks",
desc: { useCase: "I have weekly standups, monthly reports, and daily checks that I create manually every time. Need a way to set up a task once and have it repeat.", proposedSolution: "A \"repeat\" option on tasks: daily, weekly, monthly, custom. When you complete a recurring task it auto-creates the next instance." },
author: u("Marcus"),
status: "IN_PROGRESS",
pinned: true,
tags: ["high-priority", "v2"],
votes: [
{ user: u("Lina"), importance: "critical" },
{ user: u("Tom"), importance: "critical" },
{ user: u("Priya"), importance: "important" },
{ user: u("Jesse") },
{ user: u("Noor") },
{ user: u("Seb"), importance: "important" },
{ user: u("Hana") },
{ user: u("Derek"), importance: "critical" },
{ user: u("Fran") },
{ user: anon(0) },
{ user: anon(1) },
{ user: anon(2) },
{ user: u("Yuki") },
],
statusChanges: [
{ from: "OPEN", to: "PLANNED", daysAgo: 40 },
{ from: "PLANNED", to: "IN_PROGRESS", reason: "Started work on the recurrence engine. First version will support daily/weekly/monthly/yearly.", daysAgo: 15 },
],
comments: [
{ body: "This is the biggest missing feature for me. Every other PM tool has this.", author: u("Derek"), daysAgo: 43 },
{ body: "What should happen when a recurring task is overdue? Should the next one still generate? Or should it wait until the current one is done?", author: adminUserId, isAdmin: true, daysAgo: 38,
replies: [
{ body: "Generate it anyway. If I miss one weekly report I still need to do this week's.", author: u("Marcus"), daysAgo: 37 },
{ body: "Maybe make it configurable? Some tasks make sense to skip if overdue, others don't.", author: u("Lina"), daysAgo: 37 },
],
},
{ body: "Started building this. First version will support daily/weekly/monthly/yearly with a custom interval option (e.g. every 3 days). Complex rules like \"second Tuesday of each month\" will come later.", author: adminUserId, isAdmin: true, daysAgo: 15, reactions: [{ emoji: "🎉", users: [u("Marcus"), u("Derek"), u("Lina"), u("Tom")] }] },
{ body: "Can't wait. I've been creating weekly tasks manually for months. Even just weekly repeat would save me 10 minutes every Monday.", author: anon(2), daysAgo: 12 },
],
createdDaysAgo: 45,
});
await createPost({
board: "trackpad",
type: PostType.BUG_REPORT,
title: "Drag and drop breaks if you scroll while dragging",
desc: { stepsToReproduce: "Have a board with enough cards to scroll. Start dragging a card, then scroll the board (mouse wheel or trackpad) while still holding the card.", expectedBehavior: "Card follows the cursor and drops where I release", actualBehavior: "Card snaps back to its original position. Sometimes it moves to a completely wrong column.", environment: "Chrome 122, Windows 11. Also happens on Firefox." },
author: u("Seb"),
status: "IN_PROGRESS",
category: catSlug("bug"),
votes: [
{ user: u("Marcus") },
{ user: u("Tom") },
{ user: u("Derek") },
{ user: u("Lina") },
{ user: u("Priya") },
],
statusChanges: [
{ from: "OPEN", to: "IN_PROGRESS", reason: "Reproducing. The drag library we use has a known issue with scroll containers.", daysAgo: 5 },
],
comments: [
{ body: "Happens on Mac too. The card teleports to a random spot if you scroll fast.", author: u("Marcus"), daysAgo: 8,
reactions: [{ emoji: "👍", users: [u("Seb"), u("Tom")] }],
edited: { prevBody: "Happens on Mac too. The card teleports to a random spot.", daysAgo: 8 },
},
{ body: "We're using dnd-kit and this is a known upstream issue with scroll containers. Looking at either patching it or switching to pragmatic-drag-and-drop.", author: adminUserId, isAdmin: true, daysAgo: 5 },
],
createdDaysAgo: 10,
});
await createPost({
board: "trackpad",
type: PostType.FEATURE_REQUEST,
title: "Subtasks / checklist inside a task",
desc: { useCase: "Some tasks have multiple steps. Right now I put them in the description as a markdown checklist but that's just text, I can't track progress or assign individual items.", proposedSolution: "Actual subtasks that show completion percentage on the parent task card." },
author: u("Lina"),
status: "PLANNED",
votes: [
{ user: u("Marcus") },
{ user: u("Tom") },
{ user: u("Priya") },
{ user: u("Hana") },
{ user: u("Fran") },
{ user: anon(0) },
],
statusChanges: [
{ from: "OPEN", to: "PLANNED", reason: "Planned for the task detail rework.", daysAgo: 8 },
],
comments: [
{ body: "Even just checkboxes that save state would be a huge improvement. Doesn't need to be full subtasks with assignees on day one.", author: u("Tom"), daysAgo: 16 },
{ body: "Agreed. We'll start with checkable items and add assignee/due date per subtask later.", author: adminUserId, isAdmin: true, daysAgo: 8 },
],
createdDaysAgo: 20,
});
await createPost({
board: "trackpad",
type: PostType.FEATURE_REQUEST,
title: "Slack notifications when a task is assigned to me",
desc: { useCase: "I live in Slack. When someone assigns me a task I won't see it until I open Trackpad, which might be hours later.", proposedSolution: "Slack integration that sends a DM when you get assigned a task, when a due date is approaching, or when someone comments on your task." },
author: u("Noor"),
status: "OPEN",
category: catSlug("integrations"),
votes: [
{ user: u("Marcus") },
{ user: u("Jesse") },
{ user: u("Derek") },
{ user: u("Seb") },
],
comments: [
{ body: "Discord too please? Not everyone uses Slack.", author: u("Jesse"), daysAgo: 5, reactions: [{ emoji: "👍", users: [u("Seb")] }] },
{ body: "Even just email notifications for assignments would go a long way. I don't need a full integration.", author: anon(1), daysAgo: 4 },
],
createdDaysAgo: 8,
});
await createPost({
board: "trackpad",
type: PostType.BUG_REPORT,
title: "Due date filter shows tasks from other projects",
desc: { stepsToReproduce: "Open project A, click \"Due this week\" filter", expectedBehavior: "Only tasks from project A due this week", actualBehavior: "Shows tasks from all projects due this week", environment: "Web app, any browser" },
author: u("Priya"),
status: "DONE",
category: catSlug("bug"),
statusChanges: [
{ from: "OPEN", to: "DONE", reason: "Fixed. The filter query wasn't scoped to the project.", daysAgo: 12 },
],
votes: [{ user: u("Lina") }, { user: u("Tom") }],
comments: [
{ body: "Yep. Noticed this too. The \"Overdue\" filter also has the same problem.", author: u("Lina"), daysAgo: 15 },
{ body: "Fixed both filters. The WHERE clause was missing the project_id condition. Embarrassing but at least it was a one-liner.", author: adminUserId, isAdmin: true, daysAgo: 12, reactions: [{ emoji: "😂", users: [u("Priya"), u("Lina")] }] },
],
createdDaysAgo: 18,
});
console.log("Creating posts for Pocketcast...");
// ═══════════════════════════════════════════
// POCKETCAST - Podcast player
// ═══════════════════════════════════════════
await createPost({
board: "pocketcast",
type: PostType.FEATURE_REQUEST,
title: "Variable playback speed per show",
desc: { useCase: "I listen to conversational podcasts at 1.5x but narrative/storytelling ones at 1x. Right now I have to change the speed every time I switch shows.", proposedSolution: "Save the playback speed per podcast, not globally." },
author: u("Hana"),
status: "DONE",
pinned: true,
votes: [
{ user: u("Marcus") },
{ user: u("Tom") },
{ user: u("Priya") },
{ user: u("Jesse") },
{ user: u("Noor") },
{ user: u("Seb") },
{ user: u("Derek") },
{ user: u("Yuki") },
{ user: u("Fran") },
{ user: u("Lina") },
],
statusChanges: [
{ from: "OPEN", to: "IN_PROGRESS", daysAgo: 30 },
{ from: "IN_PROGRESS", to: "DONE", reason: "Shipped in 4.2. Long press the speed button to save per-show.", daysAgo: 20 },
],
comments: [
{ body: "Please. I listen to like 15 different shows and the speed juggling is driving me crazy.", author: u("Jesse"), daysAgo: 40 },
{ body: "This is coming in 4.2. You can long-press the speed button to set a per-show default. The global speed still applies to any show that doesn't have a custom one.", author: adminUserId, isAdmin: true, daysAgo: 25, reactions: [{ emoji: "🎉", users: [u("Hana"), u("Jesse"), u("Marcus"), u("Fran")] }] },
{ body: "4.2 is out, per-show speed is there. Works exactly how I wanted it to.", author: u("Hana"), daysAgo: 18, reactions: [{ emoji: "❤️", users: [u("Jesse")] }] },
],
createdDaysAgo: 45,
});
await createPost({
board: "pocketcast",
type: PostType.BUG_REPORT,
title: "Downloads fail silently on cellular",
desc: { stepsToReproduce: "Enable \"download on cellular\", queue an episode for download while on LTE.", expectedBehavior: "Episode downloads", actualBehavior: "Download icon spins for a second then disappears. No error message. Episode isn't downloaded. Works fine on wifi.", environment: "Android 14, Pixel 8, Pocketcast 4.1.3" },
author: u("Derek"),
status: "UNDER_REVIEW",
category: catSlug("mobile"),
tags: ["needs-repro"],
votes: [
{ user: u("Tom") },
{ user: anon(0) },
{ user: u("Seb") },
],
comments: [
{ body: "Having the same issue on a Samsung S24. Thought it was my carrier throttling but it happens on different networks too.", author: anon(0), daysAgo: 5 },
{ body: "Can you check Settings > Storage and see how much space is free? Some Android vendors kill background downloads aggressively when storage is below ~1GB. We're looking into whether we need to use WorkManager instead of our current download service.", author: adminUserId, isAdmin: true, daysAgo: 3 },
{ body: "130GB free. Definitely not a space issue.", author: u("Derek"), daysAgo: 2, reactions: [{ emoji: "👍", users: [u("Seb")] }] },
],
createdDaysAgo: 8,
});
await createPost({
board: "pocketcast",
type: PostType.FEATURE_REQUEST,
title: "Sleep timer that fades out instead of hard stopping",
desc: { useCase: "When the sleep timer runs out, the audio just cuts off abruptly and it's jarring. Would be nicer if it faded out over 30 seconds or so.", proposedSolution: "Gradual volume fade in the last 30-60 seconds before the timer ends." },
author: u("Yuki"),
status: "OPEN",
votes: [
{ user: u("Hana") },
{ user: u("Fran") },
{ user: u("Noor") },
{ user: u("Lina") },
{ user: u("Marcus") },
],
comments: [
{ body: "Overcast does this and it's so much nicer. I always wake up when my current app just stops mid-sentence.", author: u("Noor"), daysAgo: 6 },
{ body: "A \"finish current chapter\" option for audiobooks would be great too. Some podcast apps treat chapters as segments.", author: u("Fran"), daysAgo: 4 },
{ body: "30 second fade would be perfect. I fall asleep to podcasts every night and the abrupt stop always jolts me awake.", author: anon(0), daysAgo: 3 },
],
edited: { prevTitle: "Sleep timer should fade audio out gradually", daysAgo: 8 },
createdDaysAgo: 9,
});
await createPost({
board: "pocketcast",
type: PostType.FEATURE_REQUEST,
title: "CarPlay layout is too cramped",
desc: { useCase: "The CarPlay screen shows tiny text and too many buttons. Hard to use while driving (which is the whole point).", proposedSolution: "Bigger text, fewer buttons, maybe a simplified layout for CarPlay specifically. The artwork is taking up half the screen." },
author: u("Tom"),
status: "OPEN",
category: catSlug("mobile"),
votes: [
{ user: u("Derek") },
{ user: u("Marcus") },
],
comments: [],
createdDaysAgo: 3,
});
await createPost({
board: "pocketcast",
type: PostType.BUG_REPORT,
title: "\"Mark as played\" doesn't sync between devices",
desc: { stepsToReproduce: "Mark an episode as played on phone. Open the app on tablet. Episode still shows as unplayed.", expectedBehavior: "Played status syncs across devices", actualBehavior: "Only syncs after force-closing and reopening the app on the other device. Pull to refresh doesn't do it.", environment: "iPhone 15 Pro + iPad Air M2, both on latest app version" },
author: u("Fran"),
status: "IN_PROGRESS",
category: catSlug("bug"),
votes: [
{ user: u("Hana") },
{ user: u("Jesse") },
{ user: anon(1) },
],
statusChanges: [
{ from: "OPEN", to: "IN_PROGRESS", reason: "The sync interval for playback state is 15 minutes. Looking at making it push-based instead of polling.", daysAgo: 4 },
],
comments: [
{ body: "Same issue with playback position. I'll be 30 minutes into an episode on my phone and my iPad wants to start from 0.", author: u("Hana"), daysAgo: 8 },
{ body: "I can confirm this on two Android devices. Marking played on my phone doesn't show up on my tablet until I kill the app.", author: anon(2), daysAgo: 6 },
],
createdDaysAgo: 12,
});
console.log("Creating posts for Greenline...");
// ═══════════════════════════════════════════
// GREENLINE - Public transit
// ═══════════════════════════════════════════
await createPost({
board: "greenline",
type: PostType.FEATURE_REQUEST,
title: "Show how full the bus/train is",
desc: { useCase: "During rush hour some buses are packed and I'd rather wait for the next one. Transit agencies sometimes publish occupancy data via GTFS-RT.", proposedSolution: "If the transit agency provides real-time occupancy data, show it on the route view. Even a simple empty/half/full indicator would help." },
author: u("Noor"),
status: "PLANNED",
pinned: true,
tags: ["v2"],
votes: [
{ user: u("Marcus"), importance: "important" },
{ user: u("Lina") },
{ user: u("Tom") },
{ user: u("Priya"), importance: "nice_to_have" },
{ user: u("Jesse") },
{ user: u("Hana") },
{ user: u("Seb") },
{ user: u("Derek") },
{ user: u("Yuki") },
{ user: anon(0) },
{ user: anon(1) },
{ user: anon(2) },
{ user: u("Fran") },
],
statusChanges: [
{ from: "OPEN", to: "PLANNED", reason: "We've started collecting GTFS-RT OccupancyStatus where available. Will add to the UI once we have enough coverage.", daysAgo: 10 },
],
comments: [
{ body: "NYC MTA publishes this data for subways already. Google Maps shows it sometimes. Would be great to have it here too.", author: u("Marcus"), daysAgo: 28 },
{ body: "We've been looking at GTFS-RT OccupancyStatus feeds. About 40% of the agencies we support publish it. Plan is to show a simple icon (empty seat, half-full, standing room, packed) when data is available.", author: adminUserId, isAdmin: true, daysAgo: 10, reactions: [{ emoji: "🎉", users: [u("Noor"), u("Marcus")] }] },
{ body: "Even if it's not available for every city, showing it where you can would be great. Some data is better than none.", author: u("Lina"), daysAgo: 8 },
],
createdDaysAgo: 30,
});
await createPost({
board: "greenline",
type: PostType.BUG_REPORT,
title: "Wrong arrival time for route 47",
desc: { stepsToReproduce: "Search for route 47 at Oak Street station, check the next arrival.", expectedBehavior: "Shows real-time arrival estimate", actualBehavior: "Shows the schedule time even when the bus is running late. The real-time feed works for other routes.", environment: "Web app and Android app" },
author: u("Seb"),
status: "DONE",
category: catSlug("bug"),
statusChanges: [
{ from: "OPEN", to: "DONE", reason: "Route 47 was mapped to the wrong GTFS trip_id in our database. Fixed the mapping.", daysAgo: 14 },
],
votes: [{ user: u("Noor") }, { user: u("Derek") }],
comments: [
{ body: "Route 47 was mapped to the wrong trip_id. We fixed the route mapping. Real-time data should show up correctly now. Let us know if you spot other routes with this problem.", author: adminUserId, isAdmin: true, daysAgo: 14 },
{ body: "Looking good now, thanks", author: u("Seb"), daysAgo: 13 },
],
createdDaysAgo: 20,
});
await createPost({
board: "greenline",
type: PostType.FEATURE_REQUEST,
title: "Favorite routes on the home screen",
desc: { useCase: "I take the same 2 routes every day. Don't want to search for them every morning. Let me pin them so they show up front and center when I open the app.", proposedSolution: "A \"favorites\" section at the top of the home screen with next arrival times for pinned routes/stops." },
author: u("Priya"),
status: "DONE",
votes: [
{ user: u("Noor") },
{ user: u("Marcus") },
{ user: u("Seb") },
{ user: u("Derek") },
{ user: u("Hana") },
{ user: u("Tom") },
],
statusChanges: [
{ from: "OPEN", to: "IN_PROGRESS", daysAgo: 40 },
{ from: "IN_PROGRESS", to: "DONE", reason: "Shipped. Star any stop to add it to your home screen.", daysAgo: 32 },
],
comments: [
{ body: "Shipped! You can now star any stop/route and it'll appear on your home screen with live arrival times. Long press to reorder.", author: adminUserId, isAdmin: true, daysAgo: 32, reactions: [{ emoji: "🎉", users: [u("Priya"), u("Noor"), u("Marcus")] }] },
],
createdDaysAgo: 50,
});
await createPost({
board: "greenline",
type: PostType.FEATURE_REQUEST,
title: "Offline timetable for when I'm underground",
desc: { useCase: "The subway stations near me have no cell signal. Would be helpful to have a cached version of the schedule so I at least know the schedule times if not the live ones.", proposedSolution: "Download and cache the static GTFS schedule for favorited routes." },
author: u("Marcus"),
status: "OPEN",
votes: [
{ user: u("Noor") },
{ user: u("Seb") },
{ user: u("Lina") },
{ user: u("Fran") },
],
comments: [
{ body: "Big yes. I moved to a city with underground stations and the app is useless down there. Even just a PDF of the timetable would be better than nothing.", author: u("Fran"), daysAgo: 5 },
{ body: "My commute goes through a tunnel and I always end up guessing when the next train is. Please do this.", author: anon(2), daysAgo: 3 },
],
createdDaysAgo: 8,
});
await createPost({
board: "greenline",
type: PostType.BUG_REPORT,
title: "App crashes when switching to dark mode on Android",
desc: { stepsToReproduce: "Open settings, toggle dark mode, app crashes immediately.", expectedBehavior: "Theme changes without crashing", actualBehavior: "App force-closes. Happens every time. If I set my system to dark mode it works fine though, only the in-app toggle crashes.", environment: "Samsung Galaxy A54, Android 14, Greenline 1.9.2" },
author: anon(2),
status: "DONE",
category: catSlug("mobile"),
statusChanges: [
{ from: "OPEN", to: "DONE", reason: "The activity was recreating before the theme value was persisted. Fixed.", daysAgo: 2 },
],
votes: [{ user: u("Derek") }],
comments: [
{ body: "Happening on my Pixel 7 too. Stock Android 14. Clearing app cache doesn't help.", author: anon(1), daysAgo: 4 },
{ body: "Quick fix on this one - the theme value was being applied before it was saved to SharedPreferences, so on activity recreation it loaded the old value and looped. Patched in 1.9.3, rolling out now.", author: adminUserId, isAdmin: true, daysAgo: 2 },
],
createdDaysAgo: 5,
});
console.log("Creating posts for Inkwell...");
// ═══════════════════════════════════════════
// INKWELL - Writing & note-taking
// ═══════════════════════════════════════════
await createPost({
board: "inkwell",
type: PostType.FEATURE_REQUEST,
title: "Backlinks / bidirectional links between notes",
desc: { useCase: "When I link to note B from note A, I want note B to automatically show a \"referenced by note A\" section. Like Obsidian or Roam do it.", proposedSolution: "Parse [[wiki-style links]] in note content and maintain a backlink index." },
author: u("Fran"),
status: "IN_PROGRESS",
pinned: true,
tags: ["high-priority", "v2"],
votes: [
{ user: u("Marcus"), importance: "critical" },
{ user: u("Lina"), importance: "critical" },
{ user: u("Hana"), importance: "important" },
{ user: u("Jesse") },
{ user: u("Noor") },
{ user: u("Yuki"), importance: "important" },
{ user: u("Tom") },
{ user: u("Priya") },
{ user: u("Derek") },
{ user: u("Seb") },
{ user: anon(0) },
{ user: anon(1) },
],
statusChanges: [
{ from: "OPEN", to: "PLANNED", daysAgo: 50 },
{ from: "PLANNED", to: "IN_PROGRESS", reason: "Started on the link parser and index. This is the biggest feature of the v2 rewrite.", daysAgo: 20 },
],
comments: [
{ body: "This is the one feature keeping me on Obsidian. If Inkwell gets this right with a clean UI I would switch immediately.", author: u("Marcus"), daysAgo: 55 },
{ body: "same. I actually like Inkwell's editor better than Obsidian's but the lack of backlinks is a dealbreaker", author: u("Lina"), daysAgo: 53, reactions: [{ emoji: "👍", users: [u("Marcus"), u("Fran"), u("Hana")] }] },
{ body: "We hear you. Backlinks are the core feature of the v2 editor rewrite. The [[link]] syntax will autocomplete note titles, and every note will have a \"linked mentions\" panel at the bottom. Working on it now.", author: adminUserId, isAdmin: true, daysAgo: 20, reactions: [{ emoji: "🎉", users: [u("Fran"), u("Marcus"), u("Lina"), u("Hana"), u("Yuki")] }] },
{ body: "Will it support aliases? Like if I link to [[JS]] I want it to resolve to my \"JavaScript\" note.", author: u("Yuki"), daysAgo: 15 },
{ body: "Yep, aliases are planned. You'll be able to set them in note metadata.", author: adminUserId, isAdmin: true, daysAgo: 14 },
{ body: "Really looking forward to this. I have about 500 notes and finding connections between them manually is impossible.", author: anon(0), daysAgo: 10 },
],
createdDaysAgo: 60,
});
await createPost({
board: "inkwell",
type: PostType.BUG_REPORT,
title: "Pasting from Google Docs adds invisible formatting",
desc: { stepsToReproduce: "Copy text from a Google Doc, paste into Inkwell editor.", expectedBehavior: "Clean text with basic formatting (bold, italic, links)", actualBehavior: "Adds a bunch of hidden span elements with Google's inline styles. The note looks normal but the markdown is full of garbage HTML.", environment: "Chrome, macOS" },
author: u("Lina"),
status: "DONE",
category: catSlug("bug"),
votes: [
{ user: u("Fran") },
{ user: u("Tom") },
{ user: u("Hana") },
{ user: u("Priya") },
],
statusChanges: [
{ from: "OPEN", to: "IN_PROGRESS", daysAgo: 18 },
{ from: "IN_PROGRESS", to: "DONE", reason: "Added an HTML sanitizer to the paste handler. It strips everything except basic formatting tags.", daysAgo: 12 },
],
comments: [
{ body: "I think this happens with Word too. Anything that puts styled HTML on the clipboard.", author: u("Tom"), daysAgo: 20 },
{ body: "Fixed. The paste handler now runs clipboard HTML through a sanitizer that only keeps p, strong, em, a, ul, ol, li, code, and br. Everything else gets stripped. Let me know if you notice any formatting that should be preserved but isn't.", author: adminUserId, isAdmin: true, daysAgo: 12, reactions: [{ emoji: "🎉", users: [u("Lina"), u("Tom")] }] },
{ body: "Tested with a big Google Doc full of headers, tables, and footnotes. Everything came through clean. Nice fix.", author: u("Lina"), daysAgo: 10 },
],
createdDaysAgo: 22,
});
await createPost({
board: "inkwell",
type: PostType.FEATURE_REQUEST,
title: "Publish a note as a public page",
desc: { useCase: "Sometimes I write something I want to share with people who don't use Inkwell. A blog post draft, meeting notes, a how-to guide.", proposedSolution: "A \"publish\" button that generates a public URL. Maybe with a custom subdomain like username.inkwell.app/note-title" },
author: u("Hana"),
status: "PLANNED",
votes: [
{ user: u("Fran") },
{ user: u("Marcus") },
{ user: u("Jesse") },
{ user: u("Yuki") },
{ user: u("Noor") },
],
statusChanges: [
{ from: "OPEN", to: "PLANNED", reason: "On the roadmap for after backlinks ship.", daysAgo: 8 },
],
comments: [
{ body: "Notion does this and honestly it's the reason half my team still uses Notion for docs we share externally.", author: u("Jesse"), daysAgo: 11 },
{ body: "Would be great if published notes could have their own theme/CSS. I want my public notes to look like my personal site, not like the Inkwell app.", author: u("Yuki"), daysAgo: 8 },
{ body: "Even without custom themes, just a clean read-only page with a shareable URL would be enough for me.", author: anon(1), daysAgo: 6 },
],
edited: { prevTitle: "Share notes as public web pages", daysAgo: 14 },
createdDaysAgo: 15,
});
await createPost({
board: "inkwell",
type: PostType.BUG_REPORT,
title: "Cursor jumps to end of line when bold text is near a link",
desc: { stepsToReproduce: "Type a sentence with **bold text** followed by a [link](url). Place cursor between the bold and the link. Type something.", expectedBehavior: "Characters appear where the cursor is", actualBehavior: "Cursor jumps to the end of the line after every keystroke. Have to click back to position each time." },
author: u("Marcus"),
status: "OPEN",
category: catSlug("bug"),
tags: ["needs-repro"],
votes: [
{ user: u("Lina") },
{ user: u("Fran") },
],
comments: [
{
body: "I think this only happens when the bold section ends right where the link starts, with no space between them. Adding a space fixes it for me but that's obviously a workaround.",
author: u("Fran"), daysAgo: 2,
edited: { prevBody: "I think this only happens when bold and link are adjacent. Adding a space between them fixes it.", daysAgo: 2 },
},
],
createdDaysAgo: 5,
});
await createPost({
board: "inkwell",
type: PostType.FEATURE_REQUEST,
title: "Vim keybindings option",
desc: { useCase: "I use vim motions in every other editor. My muscle memory makes me type :w and jk constantly in Inkwell.", proposedSolution: "A toggle in settings for vim mode, like VS Code and Obsidian have." },
author: u("Jesse"),
status: "DECLINED",
statusReason: "We get this request a lot but maintaining a vim layer is a huge ongoing effort. We'd rather focus on making the default editing experience better. CodeMirror (which we use) has a vim extension if you want to build a browser plugin for it.",
tags: ["wontfix"],
votes: [
{ user: u("Seb") },
{ user: u("Yuki") },
],
statusChanges: [
{ from: "OPEN", to: "DECLINED", reason: "We get this request a lot but maintaining a vim layer is a huge ongoing effort. We'd rather focus on making the default editing experience better. CodeMirror (which we use) has a vim extension if you want to build a browser plugin for it.", daysAgo: 25 },
],
comments: [
{ body: "Understandable tbh. Vim mode in other apps is usually half-baked anyway.", author: u("Seb"), daysAgo: 24, reactions: [{ emoji: "👍", users: [u("Jesse")] }] },
],
createdDaysAgo: 30,
});
console.log("Creating changelog entries...");
// ═══════════════════════════════════════════
// CHANGELOG ENTRIES
// ═══════════════════════════════════════════
await prisma.changelogEntry.createMany({
data: [
{
title: "2.8.3 - Rename detection fix",
body: "Fixed the file rename detection on macOS APFS and Windows NTFS volumes. Files should no longer re-upload when renamed. Also fixed an issue where the tray icon could get stuck in the syncing state on Linux.\n\nOther changes:\n- Reduced memory usage during large folder scans by about 30%\n- Fixed a crash when the sync target folder is on a network drive that disconnects mid-sync",
boardId: board("cloudpush").id,
publishedAt: daysAgo(15),
createdAt: daysAgo(15),
},
{
title: "2.8.0 - Selective sync",
body: "You can now choose which folders to sync instead of syncing everything. Open the tray menu, pick \"Selective sync\", and uncheck whatever you want to skip. Changes apply after the next full scan (usually under a minute).",
boardId: board("cloudpush").id,
publishedAt: daysAgo(40),
createdAt: daysAgo(40),
},
{
title: "3.1 - Recipe URL import",
body: "You can now import recipes by pasting a URL from any site that uses JSON-LD or Microdata recipe markup (which is most food blogs). The importer pulls in the title, ingredients, steps, servings, prep time, and photo.\n\nFixes:\n- Fixed the shopping list not updating after removing a recipe from the meal plan\n- The \"random recipe\" button no longer suggests the same recipe twice in a row",
boardId: board("mealwise").id,
publishedAt: daysAgo(35),
createdAt: daysAgo(35),
},
{
title: "1.4 - Board view improvements",
body: "Drag and drop on the kanban board is now smoother, especially when scrolling. Switched from dnd-kit to pragmatic-drag-and-drop which handles scroll containers better.\n\nAlso new: you can now collapse columns on the board view to save space. Right-click a column header to collapse it. Collapsed columns show the card count as a badge.",
boardId: board("trackpad").id,
publishedAt: daysAgo(8),
createdAt: daysAgo(8),
},
{
title: "1.3.2 - Subtask ordering fix",
body: "Fixed a bug where reordering subtasks within a task would sometimes put them in the wrong position. This happened when a task had more than 8 subtasks. Also fixed the keyboard shortcut for completing a subtask (Ctrl+Enter) not working on Firefox.",
boardId: board("trackpad").id,
publishedAt: daysAgo(22),
createdAt: daysAgo(22),
},
{
title: "4.2 - Per-show playback speed",
body: "Long-press the speed button to save a custom playback speed for the current show. The global speed still applies to any show without a custom setting.\n\nAlso in this release:\n- Fixed audio ducking not restoring volume after navigation announcements on Android Auto\n- Added skip silence sensitivity setting (low/medium/high)\n- Reduced battery drain from background feed refresh by about 20%",
boardId: board("pocketcast").id,
publishedAt: daysAgo(20),
createdAt: daysAgo(20),
},
{
title: "1.9.3 - Dark mode crash fix",
body: "Fixed a crash when toggling dark mode from the in-app settings on some Android devices. Also fixed route 47 showing schedule times instead of real-time arrivals.\n\nNew: you can now star stops to add them to your home screen with live departure times.",
boardId: board("greenline").id,
publishedAt: daysAgo(2),
createdAt: daysAgo(2),
},
{
title: "2.0 beta - Backlinks preview",
body: "The v2 editor beta is available for testing. The big new feature is [[wiki-style links]] with backlinks. Every note now has a \"linked mentions\" panel that shows which other notes reference it.\n\nThis is an early beta. Known issues:\n- Link autocomplete is slow on vaults with 1000+ notes (we're working on indexing)\n- Renaming a note doesn't update links pointing to it yet\n- The backlink panel doesn't show context (just the note title for now)\n\nTo try it: Settings > Editor > Enable v2 editor beta",
boardId: board("inkwell").id,
publishedAt: daysAgo(5),
createdAt: daysAgo(5),
},
{
title: "1.8 - Offline schedules",
body: "Schedules for your saved routes are now cached for offline use. If you lose signal underground or in a dead zone, the app will show the static timetable instead of a loading spinner. Real-time data takes over again once you reconnect.",
boardId: board("greenline").id,
publishedAt: daysAgo(28),
createdAt: daysAgo(28),
},
],
});
console.log("Creating notifications...");
// Sample notifications for various users
const notifData = [
{ type: "status_changed", title: "Status updated", body: "\"Files keep re-uploading after I rename them\" is now In Progress", userId: u("Marcus") },
{ type: "comment_reply", title: "New reply", body: "Admin replied to your comment on \"Shopping list should combine quantities\"", userId: u("Fran") },
{ type: "status_changed", title: "Status updated", body: "\"Variable playback speed per show\" is now Done", userId: u("Hana") },
{ type: "status_changed", title: "Status updated", body: "\"Recurring tasks\" is now In Progress", userId: u("Derek") },
{ type: "comment_reply", title: "New reply", body: "Lina replied to the discussion on \"Backlinks / bidirectional links\"", userId: u("Marcus") },
];
for (const n of notifData) {
await prisma.notification.create({
data: {
type: n.type,
title: n.title,
body: n.body,
userId: n.userId,
createdAt: hoursAgo(Math.random() * 72),
},
});
}
// Create activity events for status changes and comments
for (const b of boards) {
const posts = await prisma.post.findMany({ where: { boardId: b.id }, select: { id: true, title: true, status: true } });
for (const p of posts) {
if (p.status !== "OPEN") {
await prisma.activityEvent.create({
data: {
type: "status_changed",
boardId: b.id,
postId: p.id,
metadata: { title: p.title, status: p.status },
createdAt: daysAgo(Math.random() * 30),
},
});
}
}
}
console.log("Done! Created 6 boards with posts, comments, votes, tags, changelog, and notifications.");
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());