app/lib/writing.server.ts
117 lines and 13 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. It is in an import cycle. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| forbid | 15 to 17 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| statusList | 22 to 32 | 3 | 83% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| saveStatusList | 35 to 44 | 3 | 86% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:41> | 41 to 41 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| readGoal | 48 to 57 | 3 | 83% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| saveGoal | 62 to 78 | 9 | 100% | WatchCRAP 9 | No data: mutation testing has not run on this repo yet |
| addUntyped | 83 to 90 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| takeUntyped | 93 to 97 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:96> | 96 to 96 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| clearUntyped | 99 to 101 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| untypedField | 104 to 107 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
| progressEntries | 115 to 134 | 2 | 75% | OkCRAP 2.1 | No data: mutation testing has not run on this repo yet |
| <anonymous:133> | 133 to 133 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/db/schema.ts, app/lib/books.server.ts, app/lib/people.server.ts, app/lib/roles.ts, app/lib/writing/progress.ts, app/lib/writing/status.ts. Imported by app/lib/books.server.ts, app/routes/book.corkboard.tsx, app/routes/book.file.tsx, app/routes/book.outliner.tsx, app/routes/book.tsx.
Source
// The writing desk's own records in D1: each project's status list, each person's
// writing goal, the running count of words that reached a file without being typed, and the saves a
// day's progress is counted from. Git holds the text and the headers; none of this is in Git.
import { and, eq, gte, isNotNull, isNull, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { authorship, statusLabels, untypedWords, writingGoals } from "~/db/schema";
import type { BookProject } from "~/lib/books.server";
import type { Viewer } from "~/lib/people.server";
import { can } from "~/lib/roles";
import { DEFAULT_GOAL, parseWritingDays, STREAK_HORIZON_DAYS, type Goal, type SaveEntry } from "~/lib/writing/progress";
import { cleanLabels, STARTING_STATUSES, type StatusLabel } from "~/lib/writing/status";
function forbid(): never {
throw new Response("Forbidden", { status: 403 });
}
// ---------- status lists
/** The project's list, or the starting list for books while it has none of its own. */
export async function statusList(db: D1Database, project: BookProject): Promise<{ labels: StatusLabel[]; edited: boolean }> {
if (!can(project.role, "read")) forbid();
const rows = await drizzle(db)
.select({ label: statusLabels.label, color: statusLabels.color })
.from(statusLabels)
.where(eq(statusLabels.projectId, project.id))
.orderBy(statusLabels.position)
.all();
if (rows.length === 0) return { labels: STARTING_STATUSES.book, edited: false };
return { labels: rows, edited: true };
}
/** Replaces the whole list. Files keep whatever `status:` they carry; a label taken away shows as written. */
export async function saveStatusList(db: D1Database, project: BookProject, rows: { label: string; color: string | number }[]) {
if (!can(project.role, "edit")) forbid();
const clean = cleanLabels(rows);
if (!clean.ok) return clean;
await db.batch([
db.prepare("DELETE FROM status_labels WHERE project_id = ?").bind(project.id),
...clean.labels.map((l, i) => db.prepare("INSERT INTO status_labels (project_id, position, label, color) VALUES (?, ?, ?, ?)").bind(project.id, i, l.label, l.color)),
]);
return clean;
}
// ---------- goals
export async function readGoal(db: D1Database, project: BookProject, viewer: Viewer): Promise<Goal> {
if (!can(project.role, "read")) forbid();
const row = await drizzle(db)
.select()
.from(writingGoals)
.where(and(eq(writingGoals.projectId, project.id), eq(writingGoals.personId, viewer.id)))
.get();
if (!row) return DEFAULT_GOAL;
return { mode: row.mode, dailyTarget: row.dailyTarget, writingDays: parseWritingDays(row.writingDays), allowNegative: row.allowNegative };
}
const MAX_DAILY_TARGET = 100_000;
/** A person's own goal: anyone who may write in the project sets theirs, and no one else's. */
export async function saveGoal(db: D1Database, project: BookProject, viewer: Viewer, input: { mode: string; dailyTarget: string; writingDays: string[]; allowNegative: boolean }) {
if (!can(project.role, "edit")) forbid();
const mode = input.mode === "deadline" ? "deadline" : "daily";
const raw = input.dailyTarget.replace(/[,_\s]/g, "");
const dailyTarget = raw === "" ? null : Number(raw);
if (dailyTarget !== null && (!Number.isInteger(dailyTarget) || dailyTarget < 1 || dailyTarget > MAX_DAILY_TARGET)) {
return { ok: false as const, error: `Give the daily target as a whole number of words from 1 to ${MAX_DAILY_TARGET.toLocaleString("en-US")}, or leave it empty.` };
}
const days = parseWritingDays(input.writingDays.join(""));
if (days.length === 0) return { ok: false as const, error: "Choose at least one writing day." };
const values = { mode, dailyTarget, writingDays: days.join(""), allowNegative: input.allowNegative } as const;
await drizzle(db)
.insert(writingGoals)
.values({ projectId: project.id, personId: viewer.id, ...values })
.onConflictDoUpdate({ target: [writingGoals.projectId, writingGoals.personId], set: values });
return { ok: true as const };
}
// ---------- words that were not typed
/** Adds words that reached a file without being typed: a paste, a drop, an AI draft taken as the working draft. */
export async function addUntyped(db: D1Database, projectId: number, personId: number, path: string, words: number) {
const n = Math.floor(words);
if (!Number.isFinite(n) || n <= 0) return;
await drizzle(db)
.insert(untypedWords)
.values({ projectId, personId, path, words: n })
.onConflictDoUpdate({ target: [untypedWords.projectId, untypedWords.personId, untypedWords.path], set: { words: sql`${untypedWords.words} + ${n}` } });
}
/** The count since the last save, cleared: a save takes it. */
export async function takeUntyped(db: D1Database, projectId: number, personId: number, path: string): Promise<number> {
const where = and(eq(untypedWords.projectId, projectId), eq(untypedWords.personId, personId), eq(untypedWords.path, path));
const rows = await drizzle(db).delete(untypedWords).where(where).returning({ words: untypedWords.words });
return rows.reduce((n, r) => n + r.words, 0);
}
export async function clearUntyped(db: D1Database, projectId: number, personId: number, path: string) {
await takeUntyped(db, projectId, personId, path);
}
/** The words a field posted as not typed: a whole number from 0, anything else read as 0. */
export function untypedField(value: FormDataEntryValue | null): number {
const n = Number(value ?? 0);
return Number.isInteger(n) && n > 0 && n < 10_000_000 ? n : 0;
}
// ---------- the saves a day is counted from
/**
* This person's own saves in the project over the streak horizon, as typed and removed words. An AI
* client's saves and saves from before typed words were recorded are left out: they were not typed.
*/
export async function progressEntries(db: D1Database, project: BookProject, viewer: Viewer, now = new Date()): Promise<SaveEntry[]> {
if (!can(project.role, "read")) forbid();
// A day more than the horizon, so the oldest local day is whole in any zone.
const since = new Date(now.getTime() - (STREAK_HORIZON_DAYS + 1) * 86_400_000).toISOString();
const rows = await drizzle(db)
.select({ at: authorship.createdAt, typed: authorship.wordsTyped, removed: authorship.wordsRemoved })
.from(authorship)
.where(
and(
eq(authorship.projectId, project.id),
eq(authorship.personId, viewer.id),
isNull(authorship.client),
isNotNull(authorship.wordsTyped),
gte(authorship.createdAt, since),
),
)
.orderBy(authorship.createdAt)
.all();
return rows.map((r) => ({ at: r.at, typed: r.typed ?? 0, removed: r.removed }));
}