Public: anyone can read this; the crew can change it.

See the launch week on one page: which approved post and email goes out on which day, and add it to your calendar #69

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/63-see-the-launch-week-on-one-page-which-ap → main 3 commits fork

  • Merged. mason/63-see-the-launch-week-on-one-page-which-ap was merged into main as 0624ebd.

Review

Sign in to review this change.
14 files changed +508 −9
README.md +18
@@ -105,6 +105,16 @@ to make a listing of your own.
105105 link and a row per asset, each with "Approved by … on …" and its words, or
106106 "Draft — not approved yet" with no words. Print it at the end of the meeting. In the terminal, `R` on an open
107107 listing shows the same report, approved words only.
108 14. **See the launch week.** Press **Launch week** on a listing: a day-by-day
109 plan of which asset goes out when — the Coming soon teaser, launch day (MLS,
110 social posts, email blast), price and status posts, and for each open house
111 a reminder post the day before and the open house itself. Each row links to
112 its asset and says who approved it, or Draft; a draft is never shown as
113 going out. **Add to calendar** downloads `/listings/:id/schedule.ics`, built
114 by hand: all-day events, open houses at the times as typed (floating local
115 time, no time zones), launch day being the day the listing was created.
116 Nothing is sent; the app only drafts. In the terminal, `w` on an open
117 listing shows the same plan as text.
108118
109119 `src/walkthrough.test.ts` walks these steps on every `bun test`, so this
110120 list cannot drift from the app.
@@ -186,6 +196,14 @@ list cannot drift from the app.
186196 Whoever may read the listing may download it; no permission changed. The `launch_kit`
187197 MCP tool returns the same text, and `k` on an open listing in the terminal shows it
188198 (the arrow keys move between assets there).
199 **The launch week.** `GET /listings/:id/schedule` lays the listing out as a
200 day-by-day plan (built in `src/schedule.ts`): the Coming soon teaser, launch
201 day (MLS, social posts, email blast), status and price posts, and for each
202 open house a reminder post the day before. Each row names its asset, links to
203 it and says who approved it; an unapproved asset reads Draft and is never
204 shown as going out. `GET /listings/:id/schedule.ics` is the same plan as an
205 iCalendar file, written by hand with no dependency, days as typed and no time
206 zones. Whoever may read the listing may read both; nothing is written or sent.
189207 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo,
190208 price, a strip of the facts, the description, your photo, name,
191209 brokerage and license number, and the Equal Housing Opportunity line.
contract/openapi.json +38
@@ -6,6 +6,44 @@
66 "description": "One address in, a listing launched: the MLS description, three social posts, a flyer and an email blast, each checked for Fair Housing language and dressed in the agent's own brand."
77 },
88 "paths": {
9 "/listings/{id}/schedule.ics": {
10 "get": {
11 "summary": "The listing's launch week as an iCalendar file: one event per step, a draft marked DRAFT. Days as typed, no time zones. Sent as an attachment.",
12 "operationId": "getListingsIdScheduleIcs",
13 "parameters": [
14 {
15 "name": "id",
16 "in": "path",
17 "required": true,
18 "schema": {
19 "type": "integer"
20 }
21 }
22 ],
23 "responses": {
24 "200": {
25 "description": "The listing's launch week as an iCalendar file: one event per step, a draft marked DRAFT. Days as typed, no time zones. Sent as an attachment.",
26 "content": {
27 "text/calendar": {
28 "schema": {
29 "type": "string"
30 }
31 }
32 }
33 },
34 "default": {
35 "description": "{ error, message }: why not",
36 "content": {
37 "application/json": {
38 "schema": {
39 "$ref": "#/components/schemas/Error"
40 }
41 }
42 }
43 }
44 }
45 }
46 },
947 "/listings/{id}/kit.txt": {
1048 "get": {
1149 "summary": "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.",
docs/design/areas/schedule.md added +7
@@ -0,0 +1,7 @@
1 # Launch week
2
3 New component classes, all built from existing tokens (`static/app.css`):
4
5 - `schedule` — the printable page at `/listings/:id/schedule`.
6 - `schedule-days`, `schedule-day` — the ordered days, each kept whole across a printed page.
7 - `schedule-rows`, `schedule-row`, `schedule-draft`, `schedule-mark` — a day's steps; a draft row carries `schedule-draft`, and its mark says Draft.
src/a11y.test.ts +1
@@ -52,6 +52,7 @@ async function paths(): Promise<string[]> {
5252 `/listings/${id}/facts`,
5353 `/listings/${id}/flyer`,
5454 `/listings/${id}/email`,
55 `/listings/${id}/schedule`,
5556 ]),
5657 ];
5758 }
src/hostile.test.ts +1 −1
@@ -93,7 +93,7 @@ const expectInert = (html: string) => {
9393 expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/);
9494 };
9595
96 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/card", "/listings/:id/report", "/listings/:id/sign-in", "/listings/:id/email", "/homes/:id"]) {
96 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/card", "/listings/:id/report", "/listings/:id/schedule", "/listings/:id/sign-in", "/listings/:id/email", "/homes/:id"]) {
9797 test(`${path} escapes every typed word`, async () => {
9898 const r = await call(path.replace(":id", String(id)));
9999 expect(r.status).toBe(200);
src/pages/listing.ts +1
@@ -245,6 +245,7 @@ export function listingPage(input: {
245245 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
246246 ${whenLine === null ? "" : `<a class="button button-quiet" href="/listings/${l.id}/sign-in">Sign-in sheet</a>`}
247247 <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a>
248 <a class="button button-quiet" href="/listings/${l.id}/schedule">Launch week</a>
248249 <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a>
249250 <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a>
250251 </div>
src/pages/schedule.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * The launch week at `/listings/:id/schedule`: a day-by-day plan of which
3 * asset goes out when. An approved asset says who approved it; a draft says
4 * Draft and is never shown as going out.
5 */
6
7 import { escape } from "../http.ts";
8 import type { Launched } from "../listing.ts";
9 import { dayName, scheduleDays, type ScheduleRow } from "../schedule.ts";
10 import type { Who } from "../who.ts";
11 import { layout } from "./layout.ts";
12 import { tabOf } from "./listing.ts";
13
14 const row = (l: Launched, r: ScheduleRow): string =>
15 `<li class="schedule-row${r.approved === null ? " schedule-draft" : ""}">
16 <h4>${escape(r.what)}${r.start === null ? "" : ` <small class="muted">${escape(r.start)}–${escape(r.end ?? "")}</small>`}</h4>
17 <p><a href="/listings/${l.id}?tab=${tabOf(r.kind)}#${r.kind}">${escape(r.label)}</a></p>
18 <p class="schedule-mark">${r.approved === null ? "<b>Draft — not approved yet, so it does not go out</b>" : escape(r.approved)}</p>
19 </li>`;
20
21 export function schedulePage(input: { who: Who; listing: Launched }): string {
22 const { who, listing: l } = input;
23 const days = scheduleDays(l);
24 const total = days.reduce((n, d) => n + d.rows.length, 0);
25 const signed = days.reduce((n, d) => n + d.rows.filter((r) => r.approved !== null).length, 0);
26 return layout(
27 `<div class="print-bar row">
28 <a href="/listings/${l.id}">← Back to the listing</a>
29 <a class="button button-quiet" href="/listings/${l.id}/schedule.ics" download>Add to calendar</a>
30 <button type="button" class="button" data-print hidden>Print</button>
31 </div>
32 <article class="schedule">
33 <header>
34 <p class="muted">Launch week · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p>
35 <h2>${escape(l.address)}</h2>
36 <p class="muted">${signed} of ${total} steps use words a person approved. Nothing is sent from here.</p>
37 </header>
38 <ol class="schedule-days">
39 ${days
40 .map(
41 (d) => `<li class="schedule-day">
42 <h3>${escape(dayName(d.day))}</h3>
43 <ul class="schedule-rows">
44 ${d.rows.map((r) => row(l, r)).join("\n ")}
45 </ul>
46 </li>`,
47 )
48 .join("\n ")}
49 </ol>
50 </article>`,
51 who,
52 { path: `/listings/${l.id}/schedule`, heading: `Launch week: ${l.address}`, kind: "print" },
53 );
54 }
src/routes.ts +24
@@ -72,6 +72,8 @@ import { publicOrigin } from "./env.ts";
7272 import { propertyPage } from "./pages/property.ts";
7373 import { isPost, lengthLine } from "./postlength.ts";
7474 import { reportPage } from "./pages/report.ts";
75 import { schedulePage } from "./pages/schedule.ts";
76 import { scheduleIcs } from "./schedule.ts";
7577 import { signInPage } from "./pages/signin.ts";
7678 import { cardPage } from "./pages/card.ts";
7779 import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
@@ -581,6 +583,28 @@ export const routes: readonly Route[] = [
581583 reportPage({ who: c.who, listing, origin: publicOrigin }),
582584 ),
583585 },
586 {
587 method: "GET",
588 path: "/listings/:id/schedule",
589 handle: (c) =>
590 page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => schedulePage({ who: c.who, listing })),
591 },
592 {
593 method: "GET",
594 path: "/listings/:id/schedule.ics",
595 handle: (c) => {
596 const found = getListing(c.db, c.who, { id: Number(c.params.id) });
597 if (!found.ok) return text(found.message, STATUS[found.error]);
598 return new Response(scheduleIcs(found.value, publicOrigin), {
599 headers: {
600 "content-type": "text/calendar; charset=utf-8",
601 "content-disposition": `attachment; filename="launch-week-${found.value.id}.ics"`,
602 ...SECURITY,
603 },
604 });
605 },
606 api: { summary: "The listing's launch week as an iCalendar file: one event per step, a draft marked DRAFT. Days as typed, no time zones. Sent as an attachment.", answer: { type: "string" }, mime: "text/calendar" },
607 },
584608 {
585609 method: "GET",
586610 path: "/listings/:id/kit.txt",
src/schedule.test.ts added +143
@@ -0,0 +1,143 @@
1 /** The launch week: rows from status and open house, draft vs approved, and the iCalendar file. */
2 import { afterAll, expect, test } from "bun:test";
3 import { Database } from "bun:sqlite";
4 import { mkdtempSync } from "node:fs";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { serve } from "./server.ts";
8 import { addDays, icsText, fold } from "./schedule.ts";
9 import { sign } from "./who.ts";
10
11 const KEY = "a-key-only-the-forge-holds";
12 const dir = mkdtempSync(join(tmpdir(), "schedule-"));
13 const server = serve({ port: 0, dataDir: dir, identityKey: KEY });
14 const base = `http://${server.hostname}:${server.port}`;
15 afterAll(() => server.stop(true));
16
17 const as = (id: string) => {
18 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
19 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
20 };
21 const call = (path: string, who?: string, init: RequestInit = {}) =>
22 fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who) });
23
24 const fields = (address: string, extra: Record<string, string> = {}) => {
25 const form = new FormData();
26 for (const [k, v] of Object.entries({
27 address, beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000, tone: "classic", palette: "teal",
28 agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1", agent_phone: "+1 (503) 555-0100",
29 agent_email: "[email protected]", lot: "0.2 acre", features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", ...extra,
30 }))
31 form.set(k, String(v));
32 return form;
33 };
34 const launch = async (address: string, extra: Record<string, string> = {}) => {
35 const made = await call("/listings", "pat", { method: "POST", body: fields(address, extra) });
36 return /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1]!;
37 };
38
39 test("the 412 Alder Lane sample plans launch day from approved words, linked and signed", async () => {
40 const ids = [...(await (await call("/", "pat")).text()).matchAll(/href="\/listings\/(\d+)"/g)].map((m) => m[1]!);
41 let id = "";
42 for (const i of new Set(ids)) if ((await (await call(`/listings/${i}/report`)).text()).includes("412 Alder Lane")) id = i;
43 const res = await call(`/listings/${id}/schedule`);
44 expect(res.status).toBe(200);
45 const html = await res.text();
46 expect(html.match(/<li class="schedule-row/g)?.length).toBe(7);
47 expect(html).toContain("Goes on the MLS");
48 expect(html).toContain("Email blast");
49 expect(html).not.toContain("Draft — not approved");
50 expect(html).toMatch(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/);
51 expect(html).toContain(`href="/listings/${id}?tab=mls#mls"`);
52 expect(html).toContain(`/listings/${id}/schedule.ics`);
53 expect((await (await call(`/listings/${id}`)).text())).toContain(`href="/listings/${id}/schedule"`);
54 });
55
56 test("a fresh listing's rows are Draft and never described as going out; open houses add a reminder the day before", async () => {
57 const id = await launch("9 Plan Rd, Portland, OR 97201", { open1_date: "2027-03-09", open1_start: "13:00", open1_end: "15:00" });
58 const html = await (await call(`/listings/${id}/schedule`, "pat")).text();
59 const rows = html.match(/<li class="schedule-row/g)!.length;
60 expect(rows).toBe(7);
61 expect(html.match(/Draft — not approved yet, so it does not go out/g)?.length).toBe(rows);
62 expect(html).not.toContain("Approved by");
63 expect(html.indexOf("Tue 2027-03-09")).toBeGreaterThan(html.indexOf("Mon 2027-03-08"));
64 expect(html).toContain("Open house reminder post");
65 expect(html).toContain("13:00–15:00");
66 });
67
68 test("coming soon starts with a teaser post and email, before the MLS", async () => {
69 const id = await launch("3 Soon St, Portland, OR 97201");
70 const set = await call(`/listings/${id}/status`, "pat", { method: "POST", body: new URLSearchParams({ status: "coming_soon" }), headers: { ...as("pat"), origin: base } });
71 expect(set.status).toBe(303);
72 const html = await (await call(`/listings/${id}/schedule`, "pat")).text();
73 expect(html).toContain("Coming soon teaser post");
74 expect(html).toContain("Coming soon teaser email");
75 expect(html.indexOf("Coming soon teaser post")).toBeLessThan(html.indexOf("Goes on the MLS"));
76 });
77
78 test("the .ics is CRLF text/calendar with one VEVENT per row and a hostile address escaped", async () => {
79 const id = await launch("1 Bad St; DROP, TABLE\\x\nEND:VEVENT", { open1_date: "2027-03-09", open1_start: "13:00", open1_end: "15:00" });
80 const res = await call(`/listings/${id}/schedule.ics`);
81 expect(res.status).toBe(200);
82 expect(res.headers.get("content-type")).toContain("text/calendar");
83 const body = await res.text();
84 expect(body.startsWith("BEGIN:VCALENDAR\r\n")).toBe(true);
85 expect(body.endsWith("END:VCALENDAR\r\n")).toBe(true);
86 expect(body.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/);
87 expect(body.match(/^BEGIN:VEVENT/gm)?.length).toBe(7);
88 expect(body.match(/^END:VEVENT/gm)?.length).toBe(7);
89 expect(body).toContain("DTSTART:20270309T130000\r\n");
90 expect(body).toContain("DTSTART;VALUE=DATE:20270308\r\n");
91 expect(body).toContain("SUMMARY:DRAFT: ");
92 const unfolded = body.replace(/\r\n /g, "");
93 expect(unfolded).toContain("1 Bad St\\; DROP\\, TABLE\\\\x END:VEVENT");
94 for (const line of body.split("\r\n")) expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75);
95 });
96
97 test("a listing a person approved shows that asset approved and the rest Draft", async () => {
98 const id = await launch("5 Approved Ave, Portland, OR 97201");
99 const api = (await (await call(`/api/listings/${id}`, "pat")).json()) as { assets: { kind: string; updated_at: string }[] };
100 const mls = api.assets.find((a) => a.kind === "mls")!;
101 const done = await call(`/listings/${id}/assets/mls/approve`, "pat", {
102 method: "POST",
103 body: new URLSearchParams({ seen: mls.updated_at }),
104 headers: { ...as("pat"), origin: base },
105 });
106 expect(done.status).toBe(303);
107 const html = await (await call(`/listings/${id}/schedule`, "pat")).text();
108 expect(html.match(/Approved by pat/g)?.length).toBe(1);
109 expect(html.match(/Draft — not approved yet/g)?.length).toBe(html.match(/<li class="schedule-row/g)!.length - 1);
110 const ics = (await (await call(`/listings/${id}/schedule.ics`)).text()).replace(/\r\n /g, "");
111 expect(ics).toMatch(/SUMMARY:Goes on the MLS/);
112 expect(ics.match(/SUMMARY:DRAFT: /g)?.length).toBe(html.match(/<li class="schedule-row/g)!.length - 1);
113 });
114
115 test("reading the plan writes nothing and a missing listing is 404", async () => {
116 const id = await launch("7 Quiet Ct, Portland, OR 97201");
117 const dump = () => {
118 const db = new Database(join(dir, "app.db"), { readonly: true });
119 const out = ["listing", "asset", "audit", "event"].map((t) => {
120 try {
121 return JSON.stringify(db.query(`SELECT * FROM ${t}`).all());
122 } catch {
123 return "";
124 }
125 });
126 db.close();
127 return out.join("|");
128 };
129 const before = dump();
130 expect(before.length).toBeGreaterThan(10);
131 expect((await call(`/listings/${id}/schedule`, "pat")).status).toBe(200);
132 expect((await call(`/listings/${id}/schedule.ics`, "pat")).status).toBe(200);
133 expect(dump()).toBe(before);
134 expect((await call("/listings/999999/schedule")).status).toBe(404);
135 expect((await call("/listings/999999/schedule.ics")).status).toBe(404);
136 });
137
138 test("helpers: calendar arithmetic, escaping and folding", () => {
139 expect(addDays("2027-03-01", -1)).toBe("2027-02-28");
140 expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
141 expect(icsText("a,b;c\\d\ne")).toBe("a\\,b\\;c\\\\d e");
142 expect(fold("x".repeat(200)).split("\r\n").every((l) => l.length <= 75)).toBe(true);
143 });
src/schedule.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * The launch week as data: which asset goes out on which day, and the same
3 * plan as an iCalendar file. Days are as the agent typed them (open house
4 * dates) or the listing's launch date (the day it was created); nothing is
5 * converted between time zones, so a timed event is a floating local time and
6 * every other one lasts the whole day. A draft is a row marked Draft and never
7 * one that is scheduled to send. Pure: no database, no permission; whoever may
8 * read the listing may read its plan, and nothing is sent from here.
9 */
10
11 import { publicOrigin } from "./env.ts";
12 import { STATUS_KINDS, PRICE_KINDS, type Kind } from "./generate.ts";
13 import type { Launched } from "./listing.ts";
14 import { approvedLine, oneLine } from "./report.ts";
15
16 export interface ScheduleRow {
17 /** The day, as `YYYY-MM-DD`. */
18 readonly day: string;
19 /** `HH:MM` when the event has a time of day (an open house), else null. */
20 readonly start: string | null;
21 readonly end: string | null;
22 /** What happens, in a few words. */
23 readonly what: string;
24 /** The asset it uses. */
25 readonly kind: Kind;
26 readonly label: string;
27 /** "Approved by … on …", or null while the words are a draft. */
28 readonly approved: string | null;
29 }
30
31 const LAUNCH_KINDS: readonly Kind[] = ["mls", "social_short", "social_medium", "social_carousel", "email"];
32
33 /** The day after (or before, with a negative n) a `YYYY-MM-DD` date: calendar arithmetic only. */
34 export const addDays = (day: string, n: number): string => {
35 const [y, m, d] = day.split("-").map(Number) as [number, number, number];
36 return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
37 };
38
39 /** The listing's launch date: the day it was created. */
40 export const launchDay = (l: Launched): string => l.created_at.slice(0, 10);
41
42 export function scheduleRows(l: Launched): ScheduleRow[] {
43 const launch = launchDay(l);
44 const row = (day: string, what: string, kind: Kind, start: string | null = null, end: string | null = null): ScheduleRow[] => {
45 const a = l.assets.find((x) => x.kind === kind);
46 return a === undefined ? [] : [{ day, start, end, what, kind, label: a.label, approved: approvedLine(a.approval) }];
47 };
48 const rows: ScheduleRow[] = [];
49 if (l.status === "coming_soon") {
50 rows.push(...row(launch, "Coming soon teaser post", "social_status"), ...row(launch, "Coming soon teaser email", "email_status"));
51 }
52 rows.push(...LAUNCH_KINDS.flatMap((k) => row(launch, k === "mls" ? "Goes on the MLS" : k === "email" ? "Email blast" : "Social post", k)));
53 if (l.status !== "coming_soon") rows.push(...STATUS_KINDS.flatMap((k) => row(launch, k === "social_status" ? "Status post" : "Status email", k)));
54 rows.push(...PRICE_KINDS.flatMap((k) => row(launch, k === "social_price" ? "Price improvement post" : "Price improvement email", k)));
55 for (const o of l.open_houses) {
56 rows.push(...row(addDays(o.date, -1), "Open house reminder post", "social_short"));
57 rows.push(...row(o.date, "Open house", "flyer", o.start, o.end));
58 }
59 return rows.sort((a, b) => a.day.localeCompare(b.day) || (a.start ?? "").localeCompare(b.start ?? ""));
60 }
61
62 /** The rows grouped by day, in date order. */
63 export const scheduleDays = (l: Launched): { day: string; rows: ScheduleRow[] }[] => {
64 const days: { day: string; rows: ScheduleRow[] }[] = [];
65 for (const r of scheduleRows(l)) {
66 const last = days.at(-1);
67 if (last?.day === r.day) last.rows.push(r);
68 else days.push({ day: r.day, rows: [r] });
69 }
70 return days;
71 };
72
73 /** "Tue 2026-11-03" — the weekday of a typed date, without a time zone. */
74 export const dayName = (day: string): string =>
75 `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][new Date(`${day}T00:00:00Z`).getUTCDay()]} ${day}`;
76
77 /** Text value escaping (RFC 5545 §3.3.11): backslash, semicolon, comma, newline. */
78 export const icsText = (v: string): string =>
79 oneLine(v).replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,");
80
81 /** A content line folded at 75 octets, continuation lines starting with a space. */
82 export function fold(line: string): string {
83 const out: string[] = [];
84 let cur = "";
85 let bytes = 0;
86 for (const ch of line) {
87 const n = new TextEncoder().encode(ch).length;
88 if (bytes + n > (out.length === 0 ? 75 : 74)) {
89 out.push(cur);
90 cur = "";
91 bytes = 0;
92 }
93 cur += ch;
94 bytes += n;
95 }
96 out.push(cur);
97 return out.join("\r\n ");
98 }
99
100 const compact = (day: string): string => day.replace(/-/g, "");
101 const clock = (t: string): string => `${t.replace(":", "")}00`;
102 const stamp = (iso: string): string => `${compact(iso.slice(0, 10))}T${iso.slice(11, 19).replace(/:/g, "")}Z`;
103
104 /** The plan as an iCalendar file: CRLF lines, one VEVENT per row. */
105 export function scheduleIcs(l: Launched, origin = publicOrigin): string {
106 const host = new URL(origin).host;
107 const events = scheduleRows(l).flatMap((r, i) => {
108 const when =
109 r.start === null || r.end === null
110 ? [`DTSTART;VALUE=DATE:${compact(r.day)}`, `DTEND;VALUE=DATE:${compact(addDays(r.day, 1))}`]
111 : [`DTSTART:${compact(r.day)}T${clock(r.start)}`, `DTEND:${compact(r.day)}T${clock(r.end)}`];
112 const summary = `${r.approved === null ? "DRAFT: " : ""}${r.what}: ${r.label} — ${l.address}`;
113 return [
114 "BEGIN:VEVENT",
115 `UID:listing-${l.id}-${i + 1}@${host}`,
116 `DTSTAMP:${stamp(l.updated_at)}`,
117 ...when,
118 `SUMMARY:${icsText(summary)}`,
119 `DESCRIPTION:${icsText(r.approved ?? "Draft — not approved yet, so it does not go out.")}`,
120 "END:VEVENT",
121 ];
122 });
123 return `${[
124 "BEGIN:VCALENDAR",
125 "VERSION:2.0",
126 "PRODID:-//listing-launch//launch week//EN",
127 "CALSCALE:GREGORIAN",
128 `X-WR-CALNAME:${icsText(`Launch week: ${l.address}`)}`,
129 ...events,
130 "END:VCALENDAR",
131 ].map(fold).join("\r\n")}\r\n`;
132 }
133
134 /** The plan as plain text, for the terminal: a draft is marked and never described as going out. */
135 export function scheduleText(l: Launched): string {
136 const days = scheduleDays(l);
137 return `${[
138 `LAUNCH WEEK — ${oneLine(l.address)}`,
139 "Nothing is sent from here; the app only drafts.",
140 "",
141 ...days.flatMap((d) => [
142 dayName(d.day),
143 ...d.rows.map(
144 (r) =>
145 ` ${r.start === null ? "all day" : `${r.start}–${r.end}`} · ${r.what} · ${r.label} · ${r.approved ?? "Draft — not approved yet, so it does not go out"}`,
146 ),
147 "",
148 ]),
149 ].join("\n").trimEnd()}\n`;
150 }
src/speed.test.ts +2
@@ -21,6 +21,8 @@ const PAGES = [
2121 "/listings/1/flyer",
2222 "/listings/1/card",
2323 "/listings/1/email",
24 "/listings/1/schedule",
25 "/listings/1/schedule.ics",
2426 "/api/listings",
2527 ];
2628
src/tty.test.ts +26
@@ -303,3 +303,29 @@ test("the terminal's help line names the seller report", () => {
303303 tty.press("\r");
304304 expect(plain(tty.screen())).toContain("R seller report");
305305 });
306
307 test("w shows the launch week: a draft is marked and never as going out, an approved asset carries its signer", () => {
308 const { db } = fresh();
309 const tty = session(db, me);
310 tty.press("\r");
311 tty.press("w");
312 let seen = plain(tty.screen());
313 expect(seen).toContain("LAUNCH WEEK");
314 expect(seen).toContain("Goes on the MLS · MLS description · Draft — not approved yet, so it does not go out");
315 expect(seen).not.toContain("Approved by");
316 expect(seen).toContain("esc back to the assets");
317 tty.press("\x1b");
318 tty.press("a"); // approve the MLS description
319 tty.press("w");
320 seen = plain(tty.screen());
321 expect(seen).toContain("Goes on the MLS · MLS description · Approved by me on");
322 expect(seen).toContain("Draft — not approved yet");
323 expect(plain(session(db, me).screen())).not.toContain("LAUNCH WEEK");
324 });
325
326 test("the terminal's help line names the launch week", () => {
327 const { db } = fresh();
328 const tty = session(db, me);
329 tty.press("\r");
330 expect(plain(tty.screen())).toContain("w launch week");
331 });
src/tty.ts +17 −8
@@ -10,7 +10,8 @@
1010 * earlier words, esc goes back; x
1111 * removes a listing; p previews the email as the client will read it; k shows
1212 * the launch kit, the file the page offers to download; R shows the seller
13 * report, approved words only, as the page prints it.
13 * report, approved words only, as the page prints it; w shows the launch week,
14 * which asset goes out on which day, a draft marked and never as going out.
1415 * - notes: a adds, d removes.
1516 *
1617 * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI,
@@ -42,6 +43,7 @@ import {
4243 import { publicOrigin, ssh, title, where } from "./env.ts";
4344 import { kit } from "./kit.ts";
4445 import { reportText } from "./report.ts";
46 import { scheduleText } from "./schedule.ts";
4547 import { approvalLabel, propertyLine } from "./listing.ts";
4648 import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts";
4749 import { emailParts } from "./pages/email.ts";
@@ -99,6 +101,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
99101 let kitting = false;
100102 /** The seller report, as the page prints it. */
101103 let reporting = false;
104 /** The launch week, day by day. */
105 let planning = false;
102106 /** The asset whose earlier versions are listed, 1 to 5 puts one back. */
103107 let versioning: Kind | undefined;
104108 let cursor = 0;
@@ -131,6 +135,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
131135 const l = found.value;
132136 if (kitting) return kit(l).split("\n");
133137 if (reporting) return reportText(l).split("\n");
138 if (planning) return scheduleText(l).split("\n");
134139 if (versioning !== undefined) {
135140 const earlier = listVersions(db, me, { id: l.id });
136141 const list = (earlier.ok ? earlier.value[versioning] : undefined) ?? [];
@@ -177,12 +182,12 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
177182 if (reading !== undefined) {
178183 lines.push(...readLines());
179184 const found = open();
180 if (previewing || kitting || reporting) help = "esc back to the assets · q quit";
185 if (previewing || kitting || reporting || planning) help = "esc back to the assets · q quit";
181186 else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit";
182187 else help =
183188 found?.ok === true && canEditListing(me, found.value)
184 ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · k launch kit · R seller report · esc back · q quit"
185 : "j/↓↑ asset · p preview email · k launch kit · R seller report · esc back · q quit";
189 ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · k launch kit · R seller report · w launch week · esc back · q quit"
190 : "j/↓↑ asset · p preview email · k launch kit · R seller report · w launch week · esc back · q quit";
186191 } else if (view === "listings") {
187192 const rows = listings();
188193 lines.push(dim(`Showing ${found.status.replace(/[-_]/g, " ")}${found.q === "" ? "" : ` matching "${found.q}"`}`));
@@ -284,11 +289,15 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
284289 previewing = true;
285290 return undefined;
286291 }
287 if (key === "R" && !kitting && !reporting && !previewing && found?.ok === true) {
292 if (key === "R" && !kitting && !reporting && !planning && !previewing && found?.ok === true) {
288293 reporting = true;
289294 return undefined;
290295 }
291 if (key === "k" && !kitting && !reporting && !previewing && found?.ok === true) {
296 if (key === "w" && !kitting && !reporting && !planning && !previewing && found?.ok === true) {
297 planning = true;
298 return undefined;
299 }
300 if (key === "k" && !kitting && !reporting && !planning && !previewing && found?.ok === true) {
292301 kitting = true;
293302 return undefined;
294303 }
@@ -308,14 +317,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
308317 return undefined;
309318 }
310319 if (key === "\x1b") {
311 if (previewing || kitting || reporting) previewing = kitting = reporting = false;
320 if (previewing || kitting || reporting || planning) previewing = kitting = reporting = planning = false;
312321 else {
313322 reading = undefined;
314323 cursor = 0;
315324 }
316325 return undefined;
317326 }
318 if (previewing || kitting || reporting) return undefined;
327 if (previewing || kitting || reporting || planning) return undefined;
319328 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
320329 if (up) cursor = Math.max(cursor - 1, 0);
321330 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r" || key === "v")) {
static/app.css +26
@@ -1171,6 +1171,32 @@ main:focus {
11711171 }
11721172 }
11731173
1174 /* The launch week: days in order, each step with its asset and approval; no controls on paper. */
1175 .schedule {
1176 max-width: 48rem;
1177 margin: 0 auto;
1178 }
1179 .schedule-days,
1180 .schedule-rows {
1181 list-style: none;
1182 margin: 0;
1183 padding: 0;
1184 }
1185 .schedule-day {
1186 padding: var(--space-3) 0;
1187 border-top: 1px solid currentColor;
1188 break-inside: avoid;
1189 }
1190 .schedule-row {
1191 padding: var(--space-2) 0;
1192 }
1193 @media print {
1194 .schedule {
1195 max-width: none;
1196 margin: 0.6in;
1197 }
1198 }
1199
11741200 /* The square picture: 1080 × 1080 CSS pixels, the hero filling it, the agent's colour in a band along the bottom. */
11751201 @page card {
11761202 size: 1080px 1080px;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs