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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/63-see-the-launch-week-on-one-page-which-ap → main 3 commits fork
14 files
changed +508
−9
README.md +18
| @@ -105,6 +105,16 @@ to make a listing of your own. | ||
| 105 | 105 | link and a row per asset, each with "Approved by … on …" and its words, or |
| 106 | 106 | "Draft — not approved yet" with no words. Print it at the end of the meeting. In the terminal, `R` on an open |
| 107 | 107 | 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. | |
| 108 | 118 | |
| 109 | 119 | `src/walkthrough.test.ts` walks these steps on every `bun test`, so this |
| 110 | 120 | list cannot drift from the app. |
| @@ -186,6 +196,14 @@ list cannot drift from the app. | ||
| 186 | 196 | Whoever may read the listing may download it; no permission changed. The `launch_kit` |
| 187 | 197 | MCP tool returns the same text, and `k` on an open listing in the terminal shows it |
| 188 | 198 | (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. | |
| 189 | 207 | 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo, |
| 190 | 208 | price, a strip of the facts, the description, your photo, name, |
| 191 | 209 | brokerage and license number, and the Equal Housing Opportunity line. |
contract/openapi.json +38
| @@ -6,6 +6,44 @@ | ||
| 6 | 6 | "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." |
| 7 | 7 | }, |
| 8 | 8 | "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 | }, | |
| 9 | 47 | "/listings/{id}/kit.txt": { |
| 10 | 48 | "get": { |
| 11 | 49 | "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[]> { | ||
| 52 | 52 | `/listings/${id}/facts`, |
| 53 | 53 | `/listings/${id}/flyer`, |
| 54 | 54 | `/listings/${id}/email`, |
| 55 | `/listings/${id}/schedule`, | |
| 55 | 56 | ]), |
| 56 | 57 | ]; |
| 57 | 58 | } |
src/hostile.test.ts +1 −1
| @@ -93,7 +93,7 @@ const expectInert = (html: string) => { | ||
| 93 | 93 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 94 | 94 | }; |
| 95 | 95 | |
| 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"]) { | |
| 97 | 97 | test(`${path} escapes every typed word`, async () => { |
| 98 | 98 | const r = await call(path.replace(":id", String(id))); |
| 99 | 99 | expect(r.status).toBe(200); |
src/pages/listing.ts +1
| @@ -245,6 +245,7 @@ export function listingPage(input: { | ||
| 245 | 245 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| 246 | 246 | ${whenLine === null ? "" : `<a class="button button-quiet" href="/listings/${l.id}/sign-in">Sign-in sheet</a>`} |
| 247 | 247 | <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> | |
| 248 | 249 | <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a> |
| 249 | 250 | <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a> |
| 250 | 251 | </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"; | ||
| 72 | 72 | import { propertyPage } from "./pages/property.ts"; |
| 73 | 73 | import { isPost, lengthLine } from "./postlength.ts"; |
| 74 | 74 | import { reportPage } from "./pages/report.ts"; |
| 75 | import { schedulePage } from "./pages/schedule.ts"; | |
| 76 | import { scheduleIcs } from "./schedule.ts"; | |
| 75 | 77 | import { signInPage } from "./pages/signin.ts"; |
| 76 | 78 | import { cardPage } from "./pages/card.ts"; |
| 77 | 79 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| @@ -581,6 +583,28 @@ export const routes: readonly Route[] = [ | ||
| 581 | 583 | reportPage({ who: c.who, listing, origin: publicOrigin }), |
| 582 | 584 | ), |
| 583 | 585 | }, |
| 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 | }, | |
| 584 | 608 | { |
| 585 | 609 | method: "GET", |
| 586 | 610 | 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 = [ | ||
| 21 | 21 | "/listings/1/flyer", |
| 22 | 22 | "/listings/1/card", |
| 23 | 23 | "/listings/1/email", |
| 24 | "/listings/1/schedule", | |
| 25 | "/listings/1/schedule.ics", | |
| 24 | 26 | "/api/listings", |
| 25 | 27 | ]; |
| 26 | 28 |
src/tty.test.ts +26
| @@ -303,3 +303,29 @@ test("the terminal's help line names the seller report", () => { | ||
| 303 | 303 | tty.press("\r"); |
| 304 | 304 | expect(plain(tty.screen())).toContain("R seller report"); |
| 305 | 305 | }); |
| 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 @@ | ||
| 10 | 10 | * earlier words, esc goes back; x |
| 11 | 11 | * removes a listing; p previews the email as the client will read it; k shows |
| 12 | 12 | * 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. | |
| 14 | 15 | * - notes: a adds, d removes. |
| 15 | 16 | * |
| 16 | 17 | * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI, |
| @@ -42,6 +43,7 @@ import { | ||
| 42 | 43 | import { publicOrigin, ssh, title, where } from "./env.ts"; |
| 43 | 44 | import { kit } from "./kit.ts"; |
| 44 | 45 | import { reportText } from "./report.ts"; |
| 46 | import { scheduleText } from "./schedule.ts"; | |
| 45 | 47 | import { approvalLabel, propertyLine } from "./listing.ts"; |
| 46 | 48 | import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts"; |
| 47 | 49 | import { emailParts } from "./pages/email.ts"; |
| @@ -99,6 +101,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 99 | 101 | let kitting = false; |
| 100 | 102 | /** The seller report, as the page prints it. */ |
| 101 | 103 | let reporting = false; |
| 104 | /** The launch week, day by day. */ | |
| 105 | let planning = false; | |
| 102 | 106 | /** The asset whose earlier versions are listed, 1 to 5 puts one back. */ |
| 103 | 107 | let versioning: Kind | undefined; |
| 104 | 108 | let cursor = 0; |
| @@ -131,6 +135,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 131 | 135 | const l = found.value; |
| 132 | 136 | if (kitting) return kit(l).split("\n"); |
| 133 | 137 | if (reporting) return reportText(l).split("\n"); |
| 138 | if (planning) return scheduleText(l).split("\n"); | |
| 134 | 139 | if (versioning !== undefined) { |
| 135 | 140 | const earlier = listVersions(db, me, { id: l.id }); |
| 136 | 141 | const list = (earlier.ok ? earlier.value[versioning] : undefined) ?? []; |
| @@ -177,12 +182,12 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 177 | 182 | if (reading !== undefined) { |
| 178 | 183 | lines.push(...readLines()); |
| 179 | 184 | 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"; | |
| 181 | 186 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; |
| 182 | 187 | else help = |
| 183 | 188 | 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"; | |
| 186 | 191 | } else if (view === "listings") { |
| 187 | 192 | const rows = listings(); |
| 188 | 193 | 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 } | ||
| 284 | 289 | previewing = true; |
| 285 | 290 | return undefined; |
| 286 | 291 | } |
| 287 | if (key === "R" && !kitting && !reporting && !previewing && found?.ok === true) { | |
| 292 | if (key === "R" && !kitting && !reporting && !planning && !previewing && found?.ok === true) { | |
| 288 | 293 | reporting = true; |
| 289 | 294 | return undefined; |
| 290 | 295 | } |
| 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) { | |
| 292 | 301 | kitting = true; |
| 293 | 302 | return undefined; |
| 294 | 303 | } |
| @@ -308,14 +317,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 308 | 317 | return undefined; |
| 309 | 318 | } |
| 310 | 319 | if (key === "\x1b") { |
| 311 | if (previewing || kitting || reporting) previewing = kitting = reporting = false; | |
| 320 | if (previewing || kitting || reporting || planning) previewing = kitting = reporting = planning = false; | |
| 312 | 321 | else { |
| 313 | 322 | reading = undefined; |
| 314 | 323 | cursor = 0; |
| 315 | 324 | } |
| 316 | 325 | return undefined; |
| 317 | 326 | } |
| 318 | if (previewing || kitting || reporting) return undefined; | |
| 327 | if (previewing || kitting || reporting || planning) return undefined; | |
| 319 | 328 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 320 | 329 | if (up) cursor = Math.max(cursor - 1, 0); |
| 321 | 330 | 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 { | ||
| 1171 | 1171 | } |
| 1172 | 1172 | } |
| 1173 | 1173 | |
| 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() 0; | |
| 1187 | border-top: 1px solid currentColor; | |
| 1188 | break-inside: avoid; | |
| 1189 | } | |
| 1190 | .schedule-row { | |
| 1191 | padding: var() 0; | |
| 1192 | } | |
| 1193 | @media print { | |
| 1194 | .schedule { | |
| 1195 | max-width: none; | |
| 1196 | margin: 0.6in; | |
| 1197 | } | |
| 1198 | } | |
| 1199 | ||
| 1174 | 1200 | /* The square picture: 1080 × 1080 CSS pixels, the hero filling it, the agent's colour in a band along the bottom. */ |
| 1175 | 1201 | @page card { |
| 1176 | 1202 | size: 1080px 1080px; |