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

See the coming week across every listing on one page: what goes out each day, which open houses, and what still needs approval #94

Merged opened this

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

Merged

mason/listing-launch:mason/92-see-the-coming-week-across-every-listing → main 2 commits fork

  • Merged. mason/92-see-the-coming-week-across-every-listing was merged into main as 22045f3.

Review

Sign in to review this change.
13 files changed +324 −10
README.md +12
@@ -263,6 +263,18 @@ list cannot drift from the app.
263263 shown as going out. `GET /listings/:id/schedule.ics` is the same plan as an
264264 iCalendar file, written by hand with no dependency, days as typed and no time
265265 zones. Whoever may read the listing may read both; nothing is written or sent.
266 **This week, across every listing.** `GET /week` (the home list's **This
267 week** link) shows the next seven days from today, a section per day with
268 every row of every listing you may see: the address linking to the listing,
269 what goes out, the open house times, and who approved it or Draft. Above the
270 days, each listing with assets still awaiting approval says how many are
271 approved ("3 of 7 approved"). To add it to a calendar, open **Add to
272 calendar** (or subscribe/import `/week.ics`): the same seven days as one
273 iCalendar file, open houses as floating local times, everything else
274 all-day. Today comes from `src/clock.ts`; nothing is sent from the page.
275 In the terminal, `w` on the listings view shows the same week as plain text
276 (still-needs-approval first, then the seven days), for the listings the
277 viewer may list; `esc` goes back.
266278 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo,
267279 price, a strip of the facts, the description, your photo, name,
268280 brokerage and license number, and the Equal Housing Opportunity line.
src/a11y.test.ts +1
@@ -47,6 +47,7 @@ async function paths(): Promise<string[]> {
4747 "/",
4848 "/notes",
4949 "/fair-housing",
50 "/week",
5051 ...ids.flatMap((id) => [
5152 `/listings/${id}`,
5253 `/listings/${id}?tab=social`,
src/actions.ts +12
@@ -578,6 +578,18 @@ export type ListingSummary = Listing & {
578578 showings: number;
579579 };
580580
581 /** Every listing the person may list and read, whole: the week across them. Each one is asked of `permit` on its own. */
582 export function listWeek(db: Database, who: Who): Result<Launched[]> {
583 const listed = listListings(db, who, { limit: 500 });
584 if (!listed.ok) return listed;
585 const all: Launched[] = [];
586 for (const s of listed.value) {
587 const got = getListing(db, who, { id: s.id });
588 if (got.ok) all.push(got.value);
589 }
590 return { ok: true, value: all };
591 }
592
581593 export function listListings(
582594 db: Database,
583595 who: Who,
src/clock.ts added +13
@@ -0,0 +1,13 @@
1 /**
2 * The one clock the app reads for "today". Tests set it; nothing else does.
3 */
4
5 let fixed: Date | undefined;
6
7 /** The current moment, or the one a test set. */
8 export const now = (): Date => fixed ?? new Date();
9
10 /** Pin the clock (a Date) or release it (undefined). For tests. */
11 export const setNow = (d: Date | undefined): void => {
12 fixed = d;
13 };
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/schedule", "/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", "/week", "/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/home.ts +1
@@ -87,6 +87,7 @@ export function home(input: {
8787 </section>
8888 <section class="launched" aria-labelledby="launched-title">
8989 <h2 id="launched-title">Launched</h2>
90 <p><a href="/week">This week</a> <span class="muted">— what goes out each day, across every listing</span></p>
9091 ${finder(listings, wanted)}
9192 <div id="listings" data-live="listing.launched listing.changed listing.removed">${list(listings, wanted)}</div>
9293 </section>
src/pages/week.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * The coming week across every listing at `/week`: what goes out each day,
3 * which open houses, and what still needs approval. Nothing is sent from here.
4 */
5
6 import { escape } from "../http.ts";
7 import type { Launched } from "../listing.ts";
8 import { awaitingApproval, dayName, weekDays, type WeekRow } from "../schedule.ts";
9 import type { Who } from "../who.ts";
10 import { layout } from "./layout.ts";
11 import { tabOf } from "./listing.ts";
12
13 const row = (r: WeekRow): string =>
14 `<li class="schedule-row${r.approved === null ? " schedule-draft" : ""}">
15 <h4><a href="/listings/${r.listing.id}">${escape(r.listing.address)}</a></h4>
16 <p>${escape(r.what)}${r.start === null ? "" : ` <small class="muted">${escape(r.start)}–${escape(r.end ?? "")}</small>`} · <a href="/listings/${r.listing.id}?tab=${tabOf(r.kind)}#${r.kind}">${escape(r.label)}</a></p>
17 <p class="schedule-mark">${r.approved === null ? "<b>Draft — not approved yet, so it does not go out</b>" : escape(r.approved)}</p>
18 </li>`;
19
20 export function weekPage(input: { who: Who; listings: readonly Launched[]; today: string }): string {
21 const { who, listings, today } = input;
22 const waiting = awaitingApproval(listings);
23 return layout(
24 `<div class="print-bar row">
25 <a href="/">← Back to the listings</a>
26 <a class="button button-quiet" href="/week.ics" download>Add to calendar</a>
27 </div>
28 <article class="schedule week">
29 <header>
30 <h2>This week</h2>
31 <p class="muted">The next seven days from ${escape(dayName(today))}, across ${listings.length} ${listings.length === 1 ? "listing" : "listings"}. Nothing is sent from here.</p>
32 </header>
33 <section aria-labelledby="week-waiting">
34 <h3 id="week-waiting">Still needs approval</h3>
35 ${
36 waiting.length === 0
37 ? `<p class="muted">Every asset is approved.</p>`
38 : `<ul class="week-waiting">${waiting
39 .map((w) => `<li><a href="/listings/${w.listing.id}">${escape(w.listing.address)}</a> <span class="muted">${w.approved} of ${w.total} approved</span></li>`)
40 .join("")}</ul>`
41 }
42 </section>
43 <ol class="schedule-days">
44 ${weekDays(listings, today)
45 .map(
46 (d) => `<li class="schedule-day">
47 <h3>${escape(dayName(d.day))}</h3>
48 ${d.rows.length === 0 ? `<p class="muted">Nothing scheduled.</p>` : `<ul class="schedule-rows">
49 ${d.rows.map(row).join("\n ")}
50 </ul>`}
51 </li>`,
52 )
53 .join("\n ")}
54 </ol>
55 </article>`,
56 who,
57 { path: "/week", heading: "This week", kind: "print" },
58 );
59 }
src/routes.ts +28 −1
@@ -36,6 +36,7 @@ import {
3636 mayLaunch,
3737 listAudit,
3838 listListings,
39 listWeek,
3940 listNotes,
4041 listVersions,
4142 restoreAsset,
@@ -82,7 +83,10 @@ import { propertyPage } from "./pages/property.ts";
8283 import { isPost, lengthLine } from "./postlength.ts";
8384 import { reportPage } from "./pages/report.ts";
8485 import { schedulePage } from "./pages/schedule.ts";
85 import { scheduleIcs } from "./schedule.ts";
86 import { scheduleIcs, weekIcs } from "./schedule.ts";
87 import { weekPage } from "./pages/week.ts";
88 import { now } from "./clock.ts";
89 import { dayOf } from "./showings.ts";
8690 import { signInPage } from "./pages/signin.ts";
8791 import { cardPage } from "./pages/card.ts";
8892 import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
@@ -653,6 +657,29 @@ export const routes: readonly Route[] = [
653657 reportPage({ who: c.who, listing, origin: publicOrigin }),
654658 ),
655659 },
660 {
661 method: "GET",
662 path: "/week",
663 handle: (c) => {
664 const found = listWeek(c.db, c.who);
665 return page(c.who, found, (listings) => weekPage({ who: c.who, listings, today: dayOf(now()) }));
666 },
667 },
668 {
669 method: "GET",
670 path: "/week.ics",
671 handle: (c) => {
672 const found = listWeek(c.db, c.who);
673 if (!found.ok) return text(found.message, STATUS[found.error]);
674 return new Response(weekIcs(found.value, dayOf(now()), publicOrigin), {
675 headers: {
676 "content-type": "text/calendar; charset=utf-8",
677 "content-disposition": 'attachment; filename="this-week.ics"',
678 ...SECURITY,
679 },
680 });
681 },
682 },
656683 {
657684 method: "GET",
658685 path: "/listings/:id/schedule",
src/schedule.ts +64 −5
@@ -71,6 +71,28 @@ export const scheduleDays = (l: Launched): { day: string; rows: ScheduleRow[] }[
7171 return days;
7272 };
7373
74 export interface WeekRow extends ScheduleRow {
75 readonly listing: Launched;
76 }
77
78 /** The next seven days from `today`, each with every listing's rows that fall on it (empty days kept). */
79 export function weekDays(listings: readonly Launched[], today: string): { day: string; rows: WeekRow[] }[] {
80 const days = Array.from({ length: 7 }, (_, i) => ({ day: addDays(today, i), rows: [] as WeekRow[] }));
81 for (const l of listings)
82 for (const r of scheduleRows(l)) days.find((d) => d.day === r.day)?.rows.push({ ...r, listing: l });
83 for (const d of days)
84 d.rows.sort((a, b) => (a.start ?? "").localeCompare(b.start ?? "") || a.listing.address.localeCompare(b.listing.address));
85 return days;
86 }
87
88 /** Each on-market listing with assets still awaiting approval, and how many of its assets are approved. */
89 export function awaitingApproval(listings: readonly Launched[]): { listing: Launched; approved: number; total: number }[] {
90 return listings
91 .filter((l) => !isOffMarket(l.status))
92 .map((l) => ({ listing: l, approved: l.assets.filter((a) => approvedLine(a.approval) !== null).length, total: l.assets.length }))
93 .filter((x) => x.approved < x.total);
94 }
95
7496 /** "Tue 2026-11-03" — the weekday of a typed date, without a time zone. */
7597 export const dayName = (day: string): string =>
7698 `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][new Date(`${day}T00:00:00Z`).getUTCDay()]} ${day}`;
@@ -102,10 +124,11 @@ const compact = (day: string): string => day.replace(/-/g, "");
102124 const clock = (t: string): string => `${t.replace(":", "")}00`;
103125 const stamp = (iso: string): string => `${compact(iso.slice(0, 10))}T${iso.slice(11, 19).replace(/:/g, "")}Z`;
104126
105 /** The plan as an iCalendar file: CRLF lines, one VEVENT per row. */
106 export function scheduleIcs(l: Launched, origin = publicOrigin): string {
127 /** One VEVENT (as lines) per row of the listing's plan that `keep` accepts; UIDs number the whole plan, so they do not move. */
128 function icsEvents(l: Launched, origin: string, keep: (day: string) => boolean = () => true): string[] {
107129 const host = new URL(origin).host;
108 const events = scheduleRows(l).flatMap((r, i) => {
130 return scheduleRows(l).flatMap((r, i) => {
131 if (!keep(r.day)) return [];
109132 const when =
110133 r.start === null || r.end === null
111134 ? [`DTSTART;VALUE=DATE:${compact(r.day)}`, `DTEND;VALUE=DATE:${compact(addDays(r.day, 1))}`]
@@ -121,15 +144,51 @@ export function scheduleIcs(l: Launched, origin = publicOrigin): string {
121144 "END:VEVENT",
122145 ];
123146 });
124 return `${[
147 }
148
149 const calendar = (name: string, events: readonly string[]): string =>
150 `${[
125151 "BEGIN:VCALENDAR",
126152 "VERSION:2.0",
127153 "PRODID:-//listing-launch//launch week//EN",
128154 "CALSCALE:GREGORIAN",
129 `X-WR-CALNAME:${icsText(`Launch week: ${l.address}`)}`,
155 `X-WR-CALNAME:${icsText(name)}`,
130156 ...events,
131157 "END:VCALENDAR",
132158 ].map(fold).join("\r\n")}\r\n`;
159
160 /** The plan as an iCalendar file: CRLF lines, one VEVENT per row. */
161 export const scheduleIcs = (l: Launched, origin = publicOrigin): string =>
162 calendar(`Launch week: ${l.address}`, icsEvents(l, origin));
163
164 /** The next seven days of every listing given, as one iCalendar file. */
165 export const weekIcs = (listings: readonly Launched[], today: string, origin = publicOrigin): string =>
166 calendar(
167 `This week from ${today}`,
168 listings.flatMap((l) => icsEvents(l, origin, (day) => day >= today && day <= addDays(today, 6))),
169 );
170
171 /** The coming week across listings as plain text, for the terminal: a draft is marked and never described as going out. */
172 export function weekText(listings: readonly Launched[], today: string): string {
173 const waiting = awaitingApproval(listings);
174 return `${[
175 `THIS WEEK — from ${dayName(today)}`,
176 "Nothing is sent from here; the app only drafts.",
177 "",
178 "Still needs approval",
179 ...(waiting.length === 0 ? [" Every asset is approved."] : waiting.map((w) => ` ${oneLine(w.listing.address)} · ${w.approved} of ${w.total} approved`)),
180 "",
181 ...weekDays(listings, today).flatMap((d) => [
182 dayName(d.day),
183 ...(d.rows.length === 0
184 ? [" Nothing scheduled."]
185 : d.rows.map(
186 (r) =>
187 ` ${r.start === null ? "all day" : `${r.start}–${r.end}`} · ${oneLine(r.listing.address)} · ${r.what} · ${r.label} · ${r.approved ?? "Draft — not approved yet, so it does not go out"}`,
188 )),
189 "",
190 ]),
191 ].join("\n").trimEnd()}\n`;
133192 }
134193
135194 /** The plan as plain text, for the terminal: a draft is marked and never described as going out. */
src/speed.test.ts +2
@@ -24,6 +24,8 @@ const PAGES = [
2424 "/fair-housing",
2525 "/listings/1/schedule",
2626 "/listings/1/schedule.ics",
27 "/week",
28 "/week.ics",
2729 "/api/listings",
2830 ];
2931
src/tty.test.ts +29
@@ -11,6 +11,7 @@ import { editAsset, getListing, insertListing } from "./actions.ts";
1111 import { publicOrigin } from "./env.ts";
1212 import { openDb } from "./db.ts";
1313 import { SAMPLE } from "./sample.ts";
14 import { setNow } from "./clock.ts";
1415 import { fromLine, session, toLine } from "./tty.ts";
1516 import type { Who } from "./who.ts";
1617
@@ -479,3 +480,31 @@ test("the terminal shows the day count in the list, and the day line and price h
479480 expect(screen).toContain(`Listed $${SAMPLE.price.toLocaleString("en-US")}`);
480481 expect(screen).toContain("(−$20,000)");
481482 });
483
484 test("w on the listings view shows the week across listings, a draft marked, and esc goes back", () => {
485 const { db } = fresh();
486 setNow(new Date());
487 try {
488 const tty = session(db, me);
489 tty.press("w");
490 const seen = plain(tty.screen());
491 expect(seen).toContain("THIS WEEK");
492 expect(seen).toContain("Still needs approval");
493 expect(seen).toContain("Draft — not approved yet, so it does not go out");
494 expect(seen).toContain("esc back to the listings");
495 tty.press("\x1b");
496 expect(plain(tty.screen())).not.toContain("THIS WEEK");
497 expect(plain(tty.screen())).toContain("w this week");
498 } finally {
499 setNow(undefined);
500 }
501 });
502
503 test("the week in the terminal asks permit per listing: a guest sees what a guest may list", () => {
504 const { db } = fresh();
505 const tty = session(db, { role: "anon" } as Who);
506 tty.press("w");
507 const seen = plain(tty.screen());
508 expect(seen).toContain("THIS WEEK");
509 expect(seen).toContain(SAMPLE.address);
510 });
src/tty.ts +19 −3
@@ -42,6 +42,7 @@ import {
4242 editAsset,
4343 getListing,
4444 listListings,
45 listWeek,
4546 listNotes,
4647 listShowings,
4748 listVisitors,
@@ -57,7 +58,8 @@ import {
5758 import { publicOrigin, ssh, title, where } from "./env.ts";
5859 import { kit } from "./kit.ts";
5960 import { reportText } from "./report.ts";
60 import { scheduleText } from "./schedule.ts";
61 import { scheduleText, weekText } from "./schedule.ts";
62 import { now } from "./clock.ts";
6163 import { approvalLabel, propertyLine } from "./listing.ts";
6264 import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts";
6365 import { emailParts } from "./pages/email.ts";
@@ -120,6 +122,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
120122 let reporting = false;
121123 /** The launch week, day by day. */
122124 let planning = false;
125 /** The coming week across every listing, over the listings view. */
126 let weeking = false;
123127 /** The asset whose earlier versions are listed, 1 to 5 puts one back. */
124128 let versioning: Kind | undefined;
125129 let cursor = 0;
@@ -225,7 +229,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
225229 "",
226230 ];
227231 let help: string;
228 if (reading !== undefined) {
232 if (reading === undefined && view === "listings" && weeking) {
233 const week = listWeek(db, me);
234 lines.push(...(week.ok ? weekText(scrub(week.value), dayOf(now())).split("\n") : [week.message]));
235 help = "esc back to the listings · q quit";
236 } else if (reading !== undefined) {
229237 lines.push(...readLines());
230238 const found = open();
231239 if (visiting === "showings") help = "j/k move · a log one · d remove · esc back to the assets · q quit";
@@ -251,7 +259,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
251259 `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`,
252260 );
253261 });
254 help = "j/k move · enter open · f filter · / search · x remove · tab notes · q quit";
262 help = "j/k move · enter open · f filter · / search · w this week · x remove · tab notes · q quit";
255263 } else {
256264 const rows = notes();
257265 if (rows.length === 0)
@@ -504,6 +512,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
504512 return undefined;
505513 }
506514
515 if (weeking) {
516 if (key === "\x1b") weeking = false;
517 return undefined;
518 }
519 if (key === "w" && view === "listings") {
520 weeking = true;
521 return undefined;
522 }
507523 if (key === "\t") {
508524 view = view === "listings" ? "notes" : "listings";
509525 cursor = 0;
src/week.test.ts added +83
@@ -0,0 +1,83 @@
1 /** The coming week across every listing: seven days, open houses, approvals waiting, the calendar file. */
2 import { afterAll, afterEach, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { setNow } from "./clock.ts";
7 import { serve } from "./server.ts";
8 import { sign } from "./who.ts";
9
10 const KEY = "a-key-only-the-forge-holds";
11 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "week-")), identityKey: KEY });
12 const base = `http://${server.hostname}:${server.port}`;
13 afterAll(() => server.stop(true));
14 afterEach(() => setNow(undefined));
15
16 const as = (id: string) => {
17 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
18 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
19 };
20 const call = (path: string, who?: string, init: RequestInit = {}) =>
21 fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who) });
22
23 const launch = async (address: string, who: string, extra: Record<string, string> = {}) => {
24 const form = new FormData();
25 for (const [k, v] of Object.entries({
26 address, beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000, tone: "classic", palette: "teal",
27 agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1", agent_phone: "+1 (503) 555-0100",
28 agent_email: "[email protected]", lot: "0.2 acre", features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", ...extra,
29 }))
30 form.set(k, String(v));
31 const made = await call("/listings", who, { method: "POST", body: form });
32 return /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1]!;
33 };
34
35 test("the week lists seven days from today, with the samples on it, and waiting approvals", async () => {
36 // The samples launched today; pin the clock once so the page and the test agree across midnight.
37 const pinned = new Date();
38 setNow(pinned);
39 const today = pinned.toISOString().slice(0, 10);
40 const res = await call("/week");
41 expect(res.status).toBe(200);
42 const html = await res.text();
43 expect(html.match(/<li class="schedule-day">/g)?.length).toBe(7);
44 expect(html).toContain("412 Alder Lane");
45 expect(html).toContain("Goes on the MLS");
46 expect(html).toContain(today);
47 expect(html).toContain('href="/week.ics"');
48 expect(html).toContain("Still needs approval");
49 });
50
51 test("the clock decides today: a pinned day moves the window and an open house lands on its day", async () => {
52 await launch("77 Clock Court", "pat", { open1_date: "2031-05-10", open1_start: "13:00", open1_end: "15:00" });
53 expect(await (await call("/week")).text()).not.toContain("2031-05-10");
54 setNow(new Date("2031-05-08T09:00:00Z"));
55 const html = await (await call("/week")).text();
56 expect(html).toContain("Thu 2031-05-08");
57 expect(html).toContain("Fri 2031-05-09");
58 expect(html).toContain("Open house reminder post");
59 expect(html).toContain("Sat 2031-05-10");
60 expect(html).toMatch(/Open house <small class="muted">13:00–15:00<\/small>/);
61 expect(html).toContain("Draft — not approved");
62 expect(html).toMatch(/77 Clock Court<\/a> <span class="muted">0 of \d+ approved/);
63 // The seven days end on the 14th.
64 expect(html).toContain("Wed 2031-05-14");
65 expect(html).not.toContain("2031-05-15");
66 });
67
68 test("the calendar file holds the same seven days, floating times and all-day otherwise", async () => {
69 setNow(new Date("2031-05-08T09:00:00Z"));
70 const res = await call("/week.ics");
71 expect(res.status).toBe(200);
72 expect(res.headers.get("content-type")).toContain("text/calendar");
73 const ics = await res.text();
74 expect(ics).toContain("DTSTART:20310510T130000");
75 expect(ics).toContain("DTEND:20310510T150000");
76 expect(ics).toContain("DTSTART;VALUE=DATE:20310509");
77 expect(ics).not.toContain("20310515");
78 expect(ics).toContain("DRAFT: ");
79 });
80
81 test("the home list links to the week", async () => {
82 expect(await (await call("/")).text()).toContain('href="/week"');
83 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs