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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/92-see-the-coming-week-across-every-listing → main 2 commits fork
13 files
changed +324
−10
README.md +12
| @@ -263,6 +263,18 @@ list cannot drift from the app. | ||
| 263 | 263 | shown as going out. `GET /listings/:id/schedule.ics` is the same plan as an |
| 264 | 264 | iCalendar file, written by hand with no dependency, days as typed and no time |
| 265 | 265 | 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. | |
| 266 | 278 | 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo, |
| 267 | 279 | price, a strip of the facts, the description, your photo, name, |
| 268 | 280 | brokerage and license number, and the Equal Housing Opportunity line. |
src/a11y.test.ts +1
| @@ -47,6 +47,7 @@ async function paths(): Promise<string[]> { | ||
| 47 | 47 | "/", |
| 48 | 48 | "/notes", |
| 49 | 49 | "/fair-housing", |
| 50 | "/week", | |
| 50 | 51 | ...ids.flatMap((id) => [ |
| 51 | 52 | `/listings/${id}`, |
| 52 | 53 | `/listings/${id}?tab=social`, |
src/actions.ts +12
| @@ -578,6 +578,18 @@ export type ListingSummary = Listing & { | ||
| 578 | 578 | showings: number; |
| 579 | 579 | }; |
| 580 | 580 | |
| 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 | ||
| 581 | 593 | export function listListings( |
| 582 | 594 | db: Database, |
| 583 | 595 | 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) => { | ||
| 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/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"]) { | |
| 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/home.ts +1
| @@ -87,6 +87,7 @@ export function home(input: { | ||
| 87 | 87 | </section> |
| 88 | 88 | <section class="launched" aria-labelledby="launched-title"> |
| 89 | 89 | <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> | |
| 90 | 91 | ${finder(listings, wanted)} |
| 91 | 92 | <div id="listings" data-live="listing.launched listing.changed listing.removed">${list(listings, wanted)}</div> |
| 92 | 93 | </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 { | ||
| 36 | 36 | mayLaunch, |
| 37 | 37 | listAudit, |
| 38 | 38 | listListings, |
| 39 | listWeek, | |
| 39 | 40 | listNotes, |
| 40 | 41 | listVersions, |
| 41 | 42 | restoreAsset, |
| @@ -82,7 +83,10 @@ import { propertyPage } from "./pages/property.ts"; | ||
| 82 | 83 | import { isPost, lengthLine } from "./postlength.ts"; |
| 83 | 84 | import { reportPage } from "./pages/report.ts"; |
| 84 | 85 | 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"; | |
| 86 | 90 | import { signInPage } from "./pages/signin.ts"; |
| 87 | 91 | import { cardPage } from "./pages/card.ts"; |
| 88 | 92 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| @@ -653,6 +657,29 @@ export const routes: readonly Route[] = [ | ||
| 653 | 657 | reportPage({ who: c.who, listing, origin: publicOrigin }), |
| 654 | 658 | ), |
| 655 | 659 | }, |
| 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 | }, | |
| 656 | 683 | { |
| 657 | 684 | method: "GET", |
| 658 | 685 | path: "/listings/:id/schedule", |
src/schedule.ts +64 −5
| @@ -71,6 +71,28 @@ export const scheduleDays = (l: Launched): { day: string; rows: ScheduleRow[] }[ | ||
| 71 | 71 | return days; |
| 72 | 72 | }; |
| 73 | 73 | |
| 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 | ||
| 74 | 96 | /** "Tue 2026-11-03" — the weekday of a typed date, without a time zone. */ |
| 75 | 97 | export const dayName = (day: string): string => |
| 76 | 98 | `${["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, ""); | ||
| 102 | 124 | const clock = (t: string): string => `${t.replace(":", "")}00`; |
| 103 | 125 | const stamp = (iso: string): string => `${compact(iso.slice(0, 10))}T${iso.slice(11, 19).replace(/:/g, "")}Z`; |
| 104 | 126 | |
| 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[] { | |
| 107 | 129 | 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 []; | |
| 109 | 132 | const when = |
| 110 | 133 | r.start === null || r.end === null |
| 111 | 134 | ? [`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 { | ||
| 121 | 144 | "END:VEVENT", |
| 122 | 145 | ]; |
| 123 | 146 | }); |
| 124 | return `${[ | |
| 147 | } | |
| 148 | ||
| 149 | const calendar = (name: string, events: readonly string[]): string => | |
| 150 | `${[ | |
| 125 | 151 | "BEGIN:VCALENDAR", |
| 126 | 152 | "VERSION:2.0", |
| 127 | 153 | "PRODID:-//listing-launch//launch week//EN", |
| 128 | 154 | "CALSCALE:GREGORIAN", |
| 129 | `X-WR-CALNAME:${icsText(`Launch week: ${l.address}`)}`, | |
| 155 | `X-WR-CALNAME:${icsText(name)}`, | |
| 130 | 156 | ...events, |
| 131 | 157 | "END:VCALENDAR", |
| 132 | 158 | ].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`; | |
| 133 | 192 | } |
| 134 | 193 | |
| 135 | 194 | /** 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 = [ | ||
| 24 | 24 | "/fair-housing", |
| 25 | 25 | "/listings/1/schedule", |
| 26 | 26 | "/listings/1/schedule.ics", |
| 27 | "/week", | |
| 28 | "/week.ics", | |
| 27 | 29 | "/api/listings", |
| 28 | 30 | ]; |
| 29 | 31 |
src/tty.test.ts +29
| @@ -11,6 +11,7 @@ import { editAsset, getListing, insertListing } from "./actions.ts"; | ||
| 11 | 11 | import { publicOrigin } from "./env.ts"; |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | 13 | import { SAMPLE } from "./sample.ts"; |
| 14 | import { setNow } from "./clock.ts"; | |
| 14 | 15 | import { fromLine, session, toLine } from "./tty.ts"; |
| 15 | 16 | import type { Who } from "./who.ts"; |
| 16 | 17 | |
| @@ -479,3 +480,31 @@ test("the terminal shows the day count in the list, and the day line and price h | ||
| 479 | 480 | expect(screen).toContain(`Listed $${SAMPLE.price.toLocaleString("en-US")}`); |
| 480 | 481 | expect(screen).toContain("(−$20,000)"); |
| 481 | 482 | }); |
| 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 { | ||
| 42 | 42 | editAsset, |
| 43 | 43 | getListing, |
| 44 | 44 | listListings, |
| 45 | listWeek, | |
| 45 | 46 | listNotes, |
| 46 | 47 | listShowings, |
| 47 | 48 | listVisitors, |
| @@ -57,7 +58,8 @@ import { | ||
| 57 | 58 | import { publicOrigin, ssh, title, where } from "./env.ts"; |
| 58 | 59 | import { kit } from "./kit.ts"; |
| 59 | 60 | import { reportText } from "./report.ts"; |
| 60 | import { scheduleText } from "./schedule.ts"; | |
| 61 | import { scheduleText, weekText } from "./schedule.ts"; | |
| 62 | import { now } from "./clock.ts"; | |
| 61 | 63 | import { approvalLabel, propertyLine } from "./listing.ts"; |
| 62 | 64 | import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts"; |
| 63 | 65 | import { emailParts } from "./pages/email.ts"; |
| @@ -120,6 +122,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 120 | 122 | let reporting = false; |
| 121 | 123 | /** The launch week, day by day. */ |
| 122 | 124 | let planning = false; |
| 125 | /** The coming week across every listing, over the listings view. */ | |
| 126 | let weeking = false; | |
| 123 | 127 | /** The asset whose earlier versions are listed, 1 to 5 puts one back. */ |
| 124 | 128 | let versioning: Kind | undefined; |
| 125 | 129 | let cursor = 0; |
| @@ -225,7 +229,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 225 | 229 | "", |
| 226 | 230 | ]; |
| 227 | 231 | 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) { | |
| 229 | 237 | lines.push(...readLines()); |
| 230 | 238 | const found = open(); |
| 231 | 239 | 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 } | ||
| 251 | 259 | `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`, |
| 252 | 260 | ); |
| 253 | 261 | }); |
| 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"; | |
| 255 | 263 | } else { |
| 256 | 264 | const rows = notes(); |
| 257 | 265 | if (rows.length === 0) |
| @@ -504,6 +512,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 504 | 512 | return undefined; |
| 505 | 513 | } |
| 506 | 514 | |
| 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 | } | |
| 507 | 523 | if (key === "\t") { |
| 508 | 524 | view = view === "listings" ? "notes" : "listings"; |
| 509 | 525 | 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 | }); |