From 7973c8659c9ffac826251a6ec7f0f07ce4c1f433 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:55:51 -0500 Subject: [PATCH 1/3] feat(web): make dashboard Home say what needs you and your next night --- apps/web/src/lib/bookings.ts | 2 +- apps/web/src/lib/components/NeedsList.svelte | 33 ++ apps/web/src/lib/components/NightList.svelte | 43 ++ .../lib/components/ProfileSetupCard.svelte | 2 +- apps/web/src/lib/format.ts | 6 + apps/web/src/lib/home.ts | 20 + apps/web/src/lib/resolved.svelte.ts | 26 ++ apps/web/src/lib/server/home.test.ts | 402 ++++++++++++++++++ apps/web/src/lib/server/home.ts | 270 ++++++++++++ .../src/lib/server/services/bookings.test.ts | 9 + apps/web/src/lib/server/services/bookings.ts | 49 ++- apps/web/src/lib/server/services/epk.ts | 23 + apps/web/src/lib/server/services/requests.ts | 26 +- apps/web/src/lib/ui/DateBlock.svelte | 14 +- apps/web/src/lib/ui/ui.svelte.test.ts | 11 + .../src/routes/dashboard/+layout.server.ts | 36 +- apps/web/src/routes/dashboard/+layout.svelte | 37 +- apps/web/src/routes/dashboard/+page.server.ts | 48 +-- apps/web/src/routes/dashboard/+page.svelte | 237 ++++------- .../bookings/[skey]/page.svelte.test.ts | 6 + .../bookings/new/page.svelte.test.ts | 3 + .../dashboard/bookings/page.svelte.test.ts | 3 + .../routes/dashboard/layout.server.test.ts | 37 +- .../routes/dashboard/layout.svelte.test.ts | 43 ++ .../src/routes/dashboard/page.server.test.ts | 94 ++-- .../src/routes/dashboard/page.stories.svelte | 146 ++++--- .../src/routes/dashboard/page.svelte.test.ts | 346 +++++++++------ .../planner/shows/[rkey]/page.svelte.test.ts | 3 + .../dashboard/profile/page.svelte.test.ts | 3 + .../projects/[slug]/page.svelte.test.ts | 3 + .../[slug]/shows/[rkey]/page.svelte.test.ts | 3 + .../requests/[rkey]/page.svelte.test.ts | 3 + 32 files changed, 1528 insertions(+), 459 deletions(-) create mode 100644 apps/web/src/lib/components/NeedsList.svelte create mode 100644 apps/web/src/lib/components/NightList.svelte create mode 100644 apps/web/src/lib/home.ts create mode 100644 apps/web/src/lib/resolved.svelte.ts create mode 100644 apps/web/src/lib/server/home.test.ts create mode 100644 apps/web/src/lib/server/home.ts diff --git a/apps/web/src/lib/bookings.ts b/apps/web/src/lib/bookings.ts index 7e5ecc454..403a11c64 100644 --- a/apps/web/src/lib/bookings.ts +++ b/apps/web/src/lib/bookings.ts @@ -71,7 +71,7 @@ export type BookingGroup = (typeof BOOKING_GROUPS)[number]['value']; /** Where a thread sits for `viewer`: a live one waits on them when someone on the other side wrote in it last. */ export function bookingGroup( - thread: Pick, + thread: Pick & { participants: { did: string; role: string }[] }, viewer: string ): BookingGroup { if (thread.status === 'confirmed') return 'confirmed'; diff --git a/apps/web/src/lib/components/NeedsList.svelte b/apps/web/src/lib/components/NeedsList.svelte new file mode 100644 index 000000000..37960d7fc --- /dev/null +++ b/apps/web/src/lib/components/NeedsList.svelte @@ -0,0 +1,33 @@ + + + diff --git a/apps/web/src/lib/components/NightList.svelte b/apps/web/src/lib/components/NightList.svelte new file mode 100644 index 000000000..7ee72a391 --- /dev/null +++ b/apps/web/src/lib/components/NightList.svelte @@ -0,0 +1,43 @@ + + + + + diff --git a/apps/web/src/lib/components/ProfileSetupCard.svelte b/apps/web/src/lib/components/ProfileSetupCard.svelte index 57915224b..45f59fdf9 100644 --- a/apps/web/src/lib/components/ProfileSetupCard.svelte +++ b/apps/web/src/lib/components/ProfileSetupCard.svelte @@ -18,7 +18,7 @@
-

Set up your profile

+

Start with your name

Your page on Local Network. Start from your Bluesky profile and add the rest later.

diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 939917067..cf097de4a 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -83,6 +83,12 @@ export function formatShowDate(iso: string | null | undefined, withTime = false) return formatDate(iso, options); } +/** A night the way lists lead with it, like "Sat 14 Nov", from a show date, which is in UTC. */ +export const nightLabel = (iso: string): string => + new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) + .format(new Date(iso)) + .replace(',', ''); + /** A profile's fee levels, reading a record written before `fees` as one level. */ export function feeLevels(profile: Pick): ProjectFee[] { return profile.fees ?? (profile.fee ? [profile.fee] : []); diff --git a/apps/web/src/lib/home.ts b/apps/web/src/lib/home.ts new file mode 100644 index 000000000..9924977da --- /dev/null +++ b/apps/web/src/lib/home.ts @@ -0,0 +1,20 @@ +/** Something waiting on the viewer, as Home lists it. `scope` is the act or thing it belongs to, for its monogram. */ +export type NeedsRow = { key: string; scope: string; title: string; detail: string; href: string; action: string }; + +/** A night the viewer has something to do with. `tentative` nights aren't settled yet; `when` says how close it is. */ +export type NightRow = { + key: string; + date: string; + title: string; + detail: string; + href: string; + tentative: boolean; + when?: 'Tonight' | 'Tomorrow'; +}; + +/** Home's heading: the one count of what's waiting on the viewer. */ +export const statusLine = (count: number): string => { + if (!count) return 'Nothing needs you.'; + + return count === 1 ? '1 thing needs you.' : `${count} things need you.`; +}; diff --git a/apps/web/src/lib/resolved.svelte.ts b/apps/web/src/lib/resolved.svelte.ts new file mode 100644 index 000000000..968792b94 --- /dev/null +++ b/apps/web/src/lib/resolved.svelte.ts @@ -0,0 +1,26 @@ +/** + * A load's value that may still be on its way: what it resolved to once it has, and `initial` until then, so a streamed + * value can join what's on the page instead of replacing a block of it. A value that's already here counts from the + * first render, the server's included. A newer promise takes over from one still waiting. + */ +export function resolved(load: () => T | Promise, initial: I) { + const first = load(); + let value = $state(first instanceof Promise ? initial : first); + + $effect(() => { + let live = true; + void Promise.resolve(load()).then((next) => { + if (live) value = next; + }); + + return () => { + live = false; + }; + }); + + return { + get value() { + return value; + } + }; +} diff --git a/apps/web/src/lib/server/home.test.ts b/apps/web/src/lib/server/home.test.ts new file mode 100644 index 000000000..80120bfe7 --- /dev/null +++ b/apps/web/src/lib/server/home.test.ts @@ -0,0 +1,402 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { HappyViewError } from '#lib/server/happyview/client.ts'; +import { silentLogger } from '#lib/server/logger.ts'; +import { ADMIN, DJ, pressKitServices, PROMOTER, STRANGER } from '#lib/server/testing/press-kits.ts'; +import { writtenLog } from '#lib/server/testing/written-log.ts'; +import type { BookingThreadView, PlannerShowView, RequestView, ShowView } from '#lib/types.ts'; +import { accessWaiting, needsCount, needsYou, nextNights, soon, withOthers } from './home'; + +let f: Awaited>; + +const NOW = Date.parse('2026-11-06T12:00:00Z'); + +beforeEach(async () => { + f = await pressKitServices(); +}); + +afterEach(async () => { + await f.t.audit.idle(); + await f.t.db.destroy(); +}); + +const inquire = async (title: string, date?: string) => + f.t.bookings.start({ + actor: await f.t.viewer(PROMOTER), + projectUri: f.project.uri, + show: { title, date: date && `${date}T00:00:00.000Z` }, + message: 'Free?' + }); + +describe('needsYou', () => { + it('lists replies by night, undated ones next, and ones whose date went by last', async () => { + await inquire('Passed', '2026-10-01'); + await inquire('Undated'); + await inquire('Later', '2026-12-12'); + await inquire('Sooner', '2026-11-14'); + + const rows = await needsYou(f.t, await f.t.viewer(DJ), NOW); + + expect(rows.map((row) => row.title)).toEqual([ + 'Reply to Sooner', + 'Reply to Later', + 'Reply to Undated', + 'Reply to Passed' + ]); + expect(rows[0]).toMatchObject({ scope: 'Neon Tide', action: 'Reply' }); + expect(rows[0].detail).toMatch(/^Sat 14 Nov · New inquiry from /); + expect(rows[2].detail).toMatch(/^No date yet · /); + expect(rows[3].detail).toMatch(/^Thu 1 Oct, date passed · /); + }); + + it('words a reply for the promoter by the act, and leaves threads out once they close or wait on the other side', async () => { + const answered = await inquire('Answered', '2026-11-14'); + const declined = await inquire('Declined', '2026-11-15'); + await inquire('Unanswered', '2026-11-16'); + const dj = await f.t.viewer(DJ); + await f.t.bookings.message({ actor: dj, space: answered, text: 'Yes' }); + await f.t.bookings.setStatus({ actor: dj, space: declined, status: 'declined' }); + + const rows = await needsYou(f.t, await f.t.viewer(PROMOTER), NOW); + + expect(rows.map((row) => row.title)).toEqual(['Answer Neon Tide about Answered']); + expect(rows[0].detail).not.toContain('New inquiry'); + expect((await needsYou(f.t, dj, NOW)).map((row) => row.title)).toEqual(['Reply to Unanswered']); + }); + + it('adds requests staff sent back, after the bookings', async () => { + await inquire('Night', '2026-11-14'); + const dj = await f.t.viewer(DJ); + + const request = await f.t.requests.submit({ + actor: dj, + kind: 'create', + profile: { ...f.project.profile, name: 'Second Act' } + }); + + await f.t.requests.decide({ + actor: await f.t.viewer(ADMIN), + uri: request.uri, + outcome: 'changesRequested', + reason: 'Link your socials' + }); + + const rows = await needsYou(f.t, dj, NOW); + + expect(rows.map((row) => row.title)).toEqual(['Reply to Night', 'Update your request']); + expect(rows[1]).toMatchObject({ action: 'Update', href: `/dashboard/requests/${request.rkey}` }); + expect(rows[1].detail).toMatch(/^New project: Second Act · Staff asked for changes · /); + }); +}); + +describe("a thread the directory can't name", () => { + const thread: BookingThreadView = { + space: 'space', + skey: '3k', + status: 'negotiating', + title: null, + showDate: '2026-11-20T00:00:00.000Z', + project: null, + promoter: { did: PROMOTER, handle: 'pat.test' }, + participants: [ + { did: PROMOTER, handle: 'pat.test', role: 'promoter' }, + { did: DJ, handle: 'neon.test', role: 'manager' } + ], + lastAuthor: DJ, + lastActivityAt: '2026-11-05T00:00:00.000Z', + createdAt: '2026-11-01T00:00:00.000Z' + }; + + it('still says what to do, without an act to name', async () => { + const app = { bookings: { threadsFor: async () => [thread] }, requests: { list: async () => [] } }; + + const [row] = await needsYou(app, await f.t.viewer(PROMOTER), NOW); + + expect(row).toMatchObject({ title: 'Reply to a booking', scope: 'a booking' }); + }); + + it('still lists the night', async () => { + const app = { + shows: { forProject: async () => [] }, + planner: { saves: async () => [], shows: async () => [] }, + projects: { byUris: async () => new Map() } + }; + + const { nights } = await nextNights( + { app, log: silentLogger }, + await f.t.viewer(PROMOTER), + [thread], + null, + { promoter: false }, + NOW + ); + + expect(nights).toMatchObject([{ title: 'Booking', detail: 'Booking · not confirmed', tentative: true }]); + }); +}); + +describe('needsCount', () => { + it('counts what needsYou lists, from the index alone', async () => { + const closed = await inquire('Closed', '2026-11-14'); + await inquire('Open', '2026-11-15'); + const dj = await f.t.viewer(DJ); + await f.t.bookings.setStatus({ actor: dj, space: closed, status: 'declined' }); + + const request = await f.t.requests.submit({ + actor: dj, + kind: 'create', + profile: { ...f.project.profile, name: 'Second Act' } + }); + + await f.t.requests.submit({ actor: dj, kind: 'create', profile: { ...f.project.profile, name: 'Third Act' } }); + await f.t.requests.decide({ + actor: await f.t.viewer(ADMIN), + uri: request.uri, + outcome: 'changesRequested', + reason: 'Link your socials' + }); + + expect(await needsCount(f.t, DJ)).toBe(2); + expect(await needsYou(f.t, dj, NOW)).toHaveLength(2); + expect(await needsCount(f.t, STRANGER)).toBe(0); + }); +}); + +describe('soon', () => { + it('hands back a result that is ready, and the promise itself for one that is not', async () => { + expect((await soon(Promise.resolve('ready'))).value).toBe('ready'); + + const slow = new Promise(() => {}); + expect((await soon(slow)).value).toBe(slow); + }); + + it('leaves a failure to stream, for the page to show', async () => { + const failed = Promise.reject(new Error('down')); + + await expect((await soon(failed)).value).rejects.toThrow('down'); + }); +}); + +describe('needsCount', () => { + it('counts what needsYou lists, from the index alone', async () => { + const closed = await inquire('Closed', '2026-11-14'); + await inquire('Open', '2026-11-15'); + const dj = await f.t.viewer(DJ); + await f.t.bookings.setStatus({ actor: dj, space: closed, status: 'declined' }); + + const request = await f.t.requests.submit({ + actor: dj, + kind: 'create', + profile: { ...f.project.profile, name: 'Second Act' } + }); + + await f.t.requests.submit({ actor: dj, kind: 'create', profile: { ...f.project.profile, name: 'Third Act' } }); + await f.t.requests.decide({ + actor: await f.t.viewer(ADMIN), + uri: request.uri, + outcome: 'changesRequested', + reason: 'Link your socials' + }); + + expect(await needsCount(f.t, DJ)).toBe(2); + expect(await needsYou(f.t, dj, NOW)).toHaveLength(2); + expect(await needsCount(f.t, STRANGER)).toBe(0); + }); +}); + +describe('soon', () => { + it('hands back a result that is ready, and the promise itself for one that is not', async () => { + expect((await soon(Promise.resolve('ready'))).value).toBe('ready'); + + const slow = new Promise(() => {}); + expect((await soon(slow)).value).toBe(slow); + }); + + it('leaves a failure to stream, for the page to show', async () => { + const failed = Promise.reject(new Error('down')); + + await expect((await soon(failed)).value).rejects.toThrow('down'); + }); +}); + +describe('accessWaiting', () => { + it("asks about each person waiting on a press kit, and drops them once they're answered", async () => { + const epk = await f.publish(DJ); + await f.t.epks.request(await f.t.viewer(STRANGER), f.kit(epk), { role: 'press' }); + + const rows = await accessWaiting({ app: f.t, log: silentLogger }, [f.project], NOW); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + scope: 'Neon Tide', + action: 'Review', + href: `/dashboard/projects/neon-tide/press-kits/${epk.rkey}/access` + }); + expect(rows[0].title).toMatch(/^Let .+ see Clubs\?$/); + + const [request] = await f.t.epks.requests(epk); + await f.t.epks.decide(await f.t.viewer(DJ), f.kit(epk), request.uri, 'approve'); + + expect(await accessWaiting({ app: f.t, log: silentLogger }, [f.project], NOW)).toEqual([]); + }); + + it("leaves them out, and says so in the log, when the press kits can't be read", async () => { + const { logger: log, notes } = writtenLog('info'); + const epks = { waiting: () => Promise.reject(new Error('directory down')) }; + + expect(await accessWaiting({ app: { epks }, log }, [f.project], NOW)).toEqual([]); + expect(notes()).toContainEqual(expect.objectContaining({ level: 'warn', err: 'directory down' })); + }); +}); + +describe('withOthers', () => { + const thread = (role: 'promoter' | 'manager'): BookingThreadView => ({ + space: role, + skey: role, + status: 'open', + title: 'Night', + showDate: null, + project: null, + promoter: { did: 'did:plc:other', handle: null }, + participants: [{ did: 'did:plc:me', handle: null, role }], + lastAuthor: 'did:plc:me', + lastActivityAt: '2026-11-01T00:00:00Z', + createdAt: '2026-11-01T00:00:00Z' + }); + + // SAFETY: withOthers only counts requests. + const request = {} as RequestView; + + it('says what is with whom, and nothing with nothing out', () => { + expect(withOthers([], [], 'did:plc:me')).toBeNull(); + expect(withOthers([thread('manager')], [], 'did:plc:me')).toBe( + '1 booking is waiting on a promoter. Nothing needed from you.' + ); + expect(withOthers([thread('promoter'), thread('promoter')], [request], 'did:plc:me')).toBe( + '2 bookings are waiting on DJs and 1 request is with staff. Nothing needed from you.' + ); + expect( + withOthers([thread('manager'), thread('manager'), thread('promoter')], [request, request], 'did:plc:me') + ).toBe( + '2 bookings are waiting on promoters and 1 booking is waiting on a DJ and 2 requests are with staff. Nothing needed from you.' + ); + }); +}); + +describe('nextNights', () => { + // SAFETY: the rows read only these fields of a show and its project. + const announced = (name: string, startsAt: string, venue?: string) => + ({ + uri: `at://did:plc:dj/show/${name}`, + rkey: name, + record: { name, startsAt, venue }, + project: f.project + }) as ShowView; + + const planned = (title: string, date?: string): PlannerShowView => ({ + rkey: title, + details: { title, date, venue: 'Kantine' }, + lineup: [{ project: f.project.uri }], + createdAt: '2026-10-01T00:00:00Z' + }); + + const open = async ({ + shows = [], + plans = [], + threads = [], + sections = { promoter: false } + }: { + shows?: ShowView[] | Error; + plans?: PlannerShowView[] | Error; + threads?: BookingThreadView[]; + sections?: { promoter: boolean } | null; + }) => { + const answer = async (result: T | Error) => { + if (result instanceof Error) throw result; + + return result; + }; + + const app = { + shows: { forProject: () => answer(shows) }, + planner: { saves: async () => [], shows: () => answer(plans) }, + projects: { byUris: async () => new Map() } + }; + + return nextNights({ app, log: silentLogger }, await f.t.viewer(DJ), threads, [f.project], sections, NOW); + }; + + it('puts shows, bookings and plans in one list by date, without what already went by', async () => { + await inquire('Rooftop', '2026-11-20'); + await inquire('Undated'); + await inquire('Over', '2026-10-01'); + + const { nights, more, failed } = await open({ + shows: [ + announced('Closing', '2026-11-07T23:30:00.000Z', 'Tiefgarage'), + announced('Old', '2026-11-05T23:30:00.000Z') + ], + plans: [planned('Winter', '2026-11-06T22:00:00.000Z'), planned('Someday')], + threads: await f.t.bookings.threadsFor(DJ), + sections: { promoter: true } + }); + + expect(nights.map(({ title, when, tentative }) => [title, when, tentative])).toEqual([ + ['Winter', 'Tonight', true], + ['Closing', 'Tomorrow', false], + ['Rooftop', undefined, true] + ]); + expect(nights[1].detail).toBe('You play 23:30 as Neon Tide · Tiefgarage'); + expect(nights[0].detail).toBe('Planned · Kantine · 1 DJ'); + expect(nights[2].detail).toBe('Booking for Neon Tide · not confirmed'); + expect(more).toBe(0); + expect(failed).toBe(false); + }); + + it('shows a booking that became a show once, as the show', async () => { + await inquire('Rooftop', '2026-11-20'); + + const { nights } = await open({ + shows: [announced('Rooftop', '2026-11-20T22:00:00.000Z')], + threads: await f.t.bookings.threadsFor(DJ) + }); + + expect(nights).toHaveLength(1); + expect(nights[0].tentative).toBe(false); + }); + + it('marks a confirmed booking settled, and stops at five', async () => { + const space = await inquire('Booked', '2026-11-08'); + await f.t.bookings.setStatus({ actor: await f.t.viewer(DJ), space, status: 'confirmed' }); + + const { nights, more } = await open({ + shows: [1, 2, 3, 4, 5, 6].map((day) => announced(`Night ${day}`, `2026-12-0${day}T22:00:00.000Z`)), + threads: await f.t.bookings.threadsFor(DJ) + }); + + expect(nights[0]).toMatchObject({ title: 'Booked', tentative: false, detail: 'Booking for Neon Tide' }); + expect(nights).toHaveLength(5); + expect(more).toBe(2); + }); + + it("leaves a performer's planner unread, but keeps it for someone whose sections aren't known", async () => { + const plans = [planned('Winter', '2026-11-20T22:00:00.000Z')]; + + expect((await open({ plans })).nights).toEqual([]); + expect((await open({ plans, sections: null })).nights).toHaveLength(1); + }); + + it('says some are missing when the directory or the planner fails, and keeps the rest', async () => { + await inquire('Rooftop', '2026-11-20'); + const threads = await f.t.bookings.threadsFor(DJ); + + const offline = await open({ shows: new HappyViewError(503, 'down'), threads }); + + expect(offline.failed).toBe(true); + expect(offline.nights.map((night) => night.title)).toEqual(['Rooftop']); + + const planner = await open({ plans: new Error('no answer'), threads, sections: { promoter: true } }); + + expect(planner.failed).toBe(true); + expect(planner.nights.map((night) => night.title)).toEqual(['Rooftop']); + }); +}); diff --git a/apps/web/src/lib/server/home.ts b/apps/web/src/lib/server/home.ts new file mode 100644 index 000000000..1a48cd7f5 --- /dev/null +++ b/apps/web/src/lib/server/home.ts @@ -0,0 +1,270 @@ +import { setTimeout as sleep } from 'node:timers/promises'; +import { bookingGroup } from '#lib/bookings.ts'; +import { formatDate, nightLabel, pluralize, requestTitle, timeAgo, accountLabel } from '#lib/format.ts'; +import type { NeedsRow, NightRow } from '#lib/home.ts'; +import type { BookingThreadView, PlannerShowView, ProjectView, RequestView, Viewer } from '#lib/types.ts'; +import { unlessOffline } from './happyview/offline'; +import type { Logger } from './logger'; +import { loadPlanner } from './planner-page'; +import type { BookingService } from './services/bookings'; +import type { EpkService } from './services/epk'; +import type { PlannerService } from './services/planner'; +import type { ProjectService } from './services/projects'; +import type { RequestService } from './services/requests'; +import type { ShowService } from './services/shows'; + +type Log = Pick; + +/** How many of an account's projects Home asks the directory about, so nobody's page waits on dozens of lookups. */ +const PROJECTS_READ = 10; + +const NIGHTS_SHOWN = 5; + +/** How long a load holds the first paint for a slower source, so a healthy one renders with no placeholders at all. */ +const SOON_MS = 150; + +/** + * `work` as a load should return it: its result if that's ready within {@link SOON_MS}, else the promise itself to + * stream. A failure streams too, for the page to show where the result would have gone. + */ +export const soon = async (work: Promise): Promise<{ value: T | Promise }> => + Promise.race([ + work.then( + (value) => ({ value }), + () => ({ value: work }) + ), + sleep(SOON_MS).then(() => ({ value: work })) + ]); + +const dayOf = (iso: string) => iso.slice(0, 10); + +const today = (now: number) => new Date(now).toISOString().slice(0, 10); + +const roleIn = (thread: BookingThreadView, did: string) => + thread.participants.find((participant) => participant.did === did)?.role; + +/** A thread waiting on `viewer`, with its night first when it has one. */ +function bookingRow(thread: BookingThreadView, viewer: Viewer, now: number): NeedsRow { + const title = thread.title ?? 'a booking'; + const act = thread.project?.name; + const isManager = roleIn(thread, viewer.did) === 'manager'; + const isPast = !!thread.showDate && dayOf(thread.showDate) < today(now); + const night = thread.showDate ? nightLabel(thread.showDate) : 'No date yet'; + const inquiry = thread.status === 'open' ? 'New inquiry from' : 'From'; + + return { + key: thread.skey, + scope: act ?? title, + title: isManager || !act ? `Reply to ${title}` : `Answer ${act} about ${title}`, + detail: [ + isPast ? `${night}, date passed` : night, + isManager && `${inquiry} ${accountLabel(thread.promoter)}`, + timeAgo(thread.lastActivityAt, now) + ] + .filter(Boolean) + .join(' · '), + href: `/dashboard/bookings/${thread.skey}`, + action: 'Reply' + }; +} + +/** Nights still to come first, soonest on top, then threads with no date, then ones whose date went by. */ +function byNight(a: BookingThreadView, b: BookingThreadView, now: number) { + const rank = ({ showDate }: BookingThreadView) => { + if (!showDate) return 1; + + return dayOf(showDate) < today(now) ? 2 : 0; + }; + + return rank(a) - rank(b) || (a.showDate ?? '').localeCompare(b.showDate ?? ''); +} + +const requestRow = (request: RequestView, now: number): NeedsRow => ({ + key: request.uri, + scope: requestTitle(request), + title: 'Update your request', + detail: `${requestTitle(request)} · Staff asked for changes · ${timeAgo(request.decision?.at ?? request.updatedAt, now)}`, + href: `/dashboard/requests/${request.rkey}`, + action: 'Update' +}); + +/** + * How many of {@link needsYou}'s rows there'll be. It reads only the site's index, so every dashboard page can say at + * once, and Home can hold the rows' room, however slow the directory is. + */ +export async function needsCount( + app: { bookings: Pick; requests: Pick }, + did: string +): Promise { + const [replies, sentBack] = await Promise.all([app.bookings.waitingOn(did), app.requests.sentBack(did)]); + + return replies + sentBack; +} + +/** What's waiting on `viewer` in the order Home shows it: bookings, then requests staff sent back. */ +export async function needsYou( + app: { bookings: Pick; requests: Pick }, + viewer: Viewer, + now = Date.now() +): Promise { + const [threads, requests] = await Promise.all([ + app.bookings.threadsFor(viewer.did), + app.requests.list({ requester: viewer.did, status: 'changes_requested' }) + ]); + + return [ + ...threads + .filter((thread) => bookingGroup(thread, viewer.did) === 'reply') + .sort((a, b) => byNight(a, b, now)) + .map((thread) => bookingRow(thread, viewer, now)), + ...requests.map((request) => requestRow(request, now)) + ]; +} + +/** People asking to see a press kit of one of `projects`. It takes the directory to know the kits, so this streams. */ +export async function accessWaiting( + { app, log }: { app: { epks: Pick }; log: Log }, + projects: ProjectView[], + now = Date.now() +): Promise { + try { + const waiting = await Promise.all(projects.slice(0, PROJECTS_READ).map((project) => app.epks.waiting(project))); + + return waiting.flat().map(({ project, epk, requester, createdAt }): NeedsRow => ({ + key: `${epk.uri} ${requester.did}`, + scope: project.profile.name, + title: `Let ${accountLabel(requester)} see ${epk.record.title}?`, + detail: `Asked ${timeAgo(createdAt, now)}`, + href: `/dashboard/projects/${project.slug}/press-kits/${epk.rkey}/access`, + action: 'Review' + })); + } catch (error) { + log.warn('could not read who is waiting on a press kit', { err: error }); + + return []; + } +} + +/** The line under Home's lists saying what's with someone else, or null with nothing out. */ +export function withOthers(threads: BookingThreadView[], requests: RequestView[], did: string): string | null { + const waiting = threads.filter((thread) => bookingGroup(thread, did) === 'active'); + const onPromoters = waiting.filter((thread) => roleIn(thread, did) === 'manager').length; + const onDjs = waiting.length - onPromoters; + + const parts = [ + onPromoters && + `${pluralize(onPromoters, 'booking')} ${onPromoters === 1 ? 'is waiting on a promoter' : 'are waiting on promoters'}`, + onDjs && `${pluralize(onDjs, 'booking')} ${onDjs === 1 ? 'is waiting on a DJ' : 'are waiting on DJs'}`, + requests.length && `${pluralize(requests.length, 'request')} ${requests.length === 1 ? 'is' : 'are'} with staff` + ].filter(Boolean); + + if (!parts.length) return null; + + return `${parts.join(' and ')}. Nothing needed from you.`; +} + +const bookingNight = (thread: BookingThreadView, did: string): NightRow => { + const isConfirmed = thread.status === 'confirmed'; + const act = thread.project?.name; + + return { + key: thread.skey, + date: dayOf(thread.showDate ?? ''), + title: thread.title ?? 'Booking', + detail: [ + act ? `Booking ${roleIn(thread, did) === 'manager' ? 'for' : 'with'} ${act}` : 'Booking', + !isConfirmed && 'not confirmed' + ] + .filter(Boolean) + .join(' · '), + href: `/dashboard/bookings/${thread.skey}`, + tentative: !isConfirmed + }; +}; + +const plannedNight = (show: PlannerShowView): NightRow => ({ + key: show.rkey, + date: dayOf(show.details.date ?? ''), + title: show.details.title, + detail: ['Planned', show.details.venue, pluralize(show.lineup.length, 'DJ')].filter(Boolean).join(' · '), + href: `/dashboard/planner/shows/${show.rkey}`, + tentative: true +}); + +const closeness = (date: string, now: number): NightRow['when'] => { + if (date === today(now)) return 'Tonight'; + + return date === today(now + 86_400_000) ? 'Tomorrow' : undefined; +}; + +/** + * The viewer's next nights across shows they announced, bookings and their planner, soonest first. Each source can + * fail alone: `failed` says some are missing, and the rest still show. A booking that became a show counts once, as + * the show. + */ +export async function nextNights( + { + app, + log + }: { + app: { + shows: Pick; + planner: Pick; + projects: Pick; + }; + log: Log; + }, + viewer: Viewer, + threads: BookingThreadView[], + projects: ProjectView[] | null, + sections: { promoter: boolean } | null, + now = Date.now() +): Promise<{ nights: NightRow[]; more: number; failed: boolean }> { + const start = today(now); + + const shows = await unlessOffline(log, () => + Promise.all((projects ?? []).slice(0, PROJECTS_READ).map((project) => app.shows.forProject(project))) + ); + + const planner = !sections || sections.promoter ? await loadPlanner({ app, log }, viewer) : null; + + const announced = (shows ?? []).flat().map(({ uri, rkey, record, project }): NightRow => ({ + key: uri, + date: dayOf(record.startsAt), + title: record.name, + detail: [ + `You play ${formatDate(record.startsAt, { timeStyle: 'short', timeZone: 'UTC', hourCycle: 'h23' })} as ${project.profile.name}`, + record.venue + ] + .filter(Boolean) + .join(' · '), + href: `/dashboard/projects/${project.slug}/shows/${rkey}`, + tentative: false + })); + + const booked = threads.filter( + (thread) => thread.showDate && ['open', 'negotiating', 'confirmed'].includes(thread.status) + ); + + const planned = (planner?.shows ?? []).filter((show) => show.details.date); + + const once = new Map(); + + for (const night of [ + ...announced, + ...booked.map((thread) => bookingNight(thread, viewer.did)), + ...planned.map(plannedNight) + ]) { + const same = `${night.date} ${night.title.toLowerCase()}`; + + if (night.date >= start && !once.has(same)) once.set(same, { ...night, when: closeness(night.date, now) }); + } + + const nights = [...once.values()].sort((a, b) => a.date.localeCompare(b.date)); + + return { + nights: nights.slice(0, NIGHTS_SHOWN), + more: Math.max(nights.length - NIGHTS_SHOWN, 0), + failed: shows === null || projects === null || (!!planner && 'problem' in planner) + }; +} diff --git a/apps/web/src/lib/server/services/bookings.test.ts b/apps/web/src/lib/server/services/bookings.test.ts index 217b734b6..6023de7d0 100644 --- a/apps/web/src/lib/server/services/bookings.test.ts +++ b/apps/web/src/lib/server/services/bookings.test.ts @@ -193,6 +193,15 @@ describe('BookingService', () => { expect((await t.bookings.threadsFor(PROMOTER)).map((thread) => thread.space)).toEqual([space]); }); + it('lists threads without their project while the directory is down', async () => { + await open(); + vi.spyOn(t.directory, 'query').mockRejectedValue(new HappyViewError(503, 'down')); + + const [thread] = await t.bookings.threadsFor(PROMOTER); + + expect(thread.project).toBeNull(); + }); + it("still tells the promoter the act wrote when the directory can't name the act", async () => { const space = await open(); vi.spyOn(t.directory, 'query').mockRejectedValue(new HappyViewError(503, 'down')); diff --git a/apps/web/src/lib/server/services/bookings.ts b/apps/web/src/lib/server/services/bookings.ts index 189015797..80cb8745f 100644 --- a/apps/web/src/lib/server/services/bookings.ts +++ b/apps/web/src/lib/server/services/bookings.ts @@ -1,7 +1,7 @@ import { XRPCError } from '@atproto/api'; import { TID } from '@atproto/common-web'; import { sql } from 'kysely'; -import { bookingActions, type BookingRole } from '#lib/bookings.ts'; +import { bookingActions, bookingGroup, type BookingRole } from '#lib/bookings.ts'; import { COLLECTION, SPACE_TYPE } from '#lib/nsid.ts'; import { BOOKING_STATUSES, isOneOf, labelFor } from '#lib/taxonomy/options.ts'; import type { BookingEventView, BookingThreadView, ShowDetails, Viewer } from '#lib/types.ts'; @@ -11,6 +11,7 @@ import { bookingSpace, parseSpaceRecordUri, parseSpaceUri } from '../atproto/spa import { type Core, ForbiddenError, NotFoundError, UserError } from '../core'; import { nowIso, parseJson } from '../db'; import type { BookingThreadRow } from '../db/schema'; +import { unlessOffline } from '../happyview/offline'; import { isFresh } from '../notifications/notifier'; import { spaceWriter } from './own-repo'; import type { ProjectService } from './projects'; @@ -757,6 +758,27 @@ export class BookingService { return this.toViews(rows); } + /** How many of `did`'s threads wait on them. It reads only the site's index, so it answers without the directory. */ + async waitingOn(did: string): Promise { + const rows = await this.core.db + .selectFrom('bookingThread') + .innerJoin('bookingParticipant', 'bookingParticipant.spaceUri', 'bookingThread.spaceUri') + .selectAll('bookingThread') + .where('bookingParticipant.did', '=', did) + .execute(); + + if (rows.length === 0) return 0; + const { participants, lastAuthors } = await this.parties(rows); + + return rows.filter( + ({ spaceUri, status }) => + bookingGroup( + { status, lastAuthor: lastAuthors.get(spaceUri) ?? null, participants: participants.get(spaceUri) ?? [] }, + did + ) === 'reply' + ).length; + } + async thread(viewer: Viewer, space: string): Promise<{ thread: BookingThreadView; events: BookingEventView[] }> { const row = await this.requireParticipant(viewer, space); const [thread] = await this.toViews([row]); @@ -792,19 +814,16 @@ export class BookingService { }; } - private async toViews(rows: BookingThreadRow[]): Promise { - if (rows.length === 0) return []; + /** Who's in each thread and who wrote in it last, keyed by space. */ + private async parties(rows: BookingThreadRow[]) { const spaces = rows.map((row) => row.spaceUri); - const participants = await this.core.db + const members = await this.core.db .selectFrom('bookingParticipant') .selectAll() .where('spaceUri', 'in', spaces) .execute(); - const accounts = await this.core.accounts.views(participants.map((p) => p.did)); - const projects = await this.projects.byUris(rows.map((row) => row.projectUri)); - // Newest first, so the first one seen for each thread is its last author. The root only opens threads. const latest = await this.core.db .selectFrom('bookingEvent') @@ -819,8 +838,22 @@ export class BookingService { for (const event of latest) if (!lastAuthors.has(event.spaceUri)) lastAuthors.set(event.spaceUri, event.authorDid); + return { members, lastAuthors, participants: Map.groupBy(members, (member) => member.spaceUri) }; + } + + private async toViews(rows: BookingThreadRow[]): Promise { + if (rows.length === 0) return []; + const { members: participants, lastAuthors } = await this.parties(rows); + + const accounts = await this.core.accounts.views(participants.map((p) => p.did)); + + // Project names are all the directory adds, so a thread stays readable without one while it's down. + const projects = await unlessOffline(this.core.logger, () => + this.projects.byUris(rows.map((row) => row.projectUri)) + ); + return rows.map((row) => { - const project = projects.get(row.projectUri); + const project = projects?.get(row.projectUri); return { space: row.spaceUri, diff --git a/apps/web/src/lib/server/services/epk.ts b/apps/web/src/lib/server/services/epk.ts index 5c00371f4..e8bba1b08 100644 --- a/apps/web/src/lib/server/services/epk.ts +++ b/apps/web/src/lib/server/services/epk.ts @@ -492,6 +492,29 @@ export class EpkService { return this.legacy.migrate(viewer); } + /** Who's waiting to hear about one of `project`'s press kits, oldest first. */ + async waiting(project: ProjectView) { + const kits = new Map((await this.kits(project)).map((epk) => [epk.uri, epk])); + + if (!kits.size) return []; + + const rows = await this.core.db + .selectFrom('epkRequest') + .selectAll() + .where('epkUri', 'in', [...kits.keys()]) + .where('status', '=', 'pending') + .orderBy('createdAt') + .execute(); + + const accounts = await this.core.accounts.views(rows.map((row) => row.requesterDid)); + + return rows.flatMap((row) => { + const epk = kits.get(row.epkUri); + + return epk ? [{ project, epk, requester: accounts.get(row.requesterDid), createdAt: row.createdAt }] : []; + }); + } + async requests(epk: EpkView): Promise { const rows = await this.core.db .selectFrom('epkRequest') diff --git a/apps/web/src/lib/server/services/requests.ts b/apps/web/src/lib/server/services/requests.ts index 4f48ebc56..4bf2976a0 100644 --- a/apps/web/src/lib/server/services/requests.ts +++ b/apps/web/src/lib/server/services/requests.ts @@ -23,6 +23,7 @@ import type { RepoClient } from '../atproto/repo-client'; import { parseSpaceRecordUri, spaceRecordUri } from '../atproto/spaces'; import { type Core, ForbiddenError, NotFoundError, UserError } from '../core'; import { nowIso, parseJson } from '../db'; +import { unlessOffline } from '../happyview/offline'; import type { RequestRow } from '../db/schema'; import { AVATAR_TYPES } from '../images'; import { diff } from './diff'; @@ -942,6 +943,18 @@ export class RequestService { return this.toViews(rows); } + /** How many of `did`'s requests staff sent back to them. It reads only the site's index. */ + async sentBack(did: string): Promise { + const { count } = await this.core.db + .selectFrom('request') + .select((eb) => eb.fn.countAll().as('count')) + .where('requesterDid', '=', did) + .where('status', '=', 'changes_requested') + .executeTakeFirstOrThrow(); + + return Number(count); + } + async counts(): Promise> { const rows = await this.core.db .selectFrom('request') @@ -965,13 +978,16 @@ export class RequestService { private async toViews(rows: RequestRow[]): Promise { const accounts = await this.core.accounts.views(rows.flatMap((row) => [row.requesterDid, row.decidedBy ?? ''])); - const projects = await this.projects.byUris([ - ...new Set(rows.flatMap((row) => [row.projectUri, row.resultProjectUri]).filter((uri): uri is string => !!uri)) - ]); + // A request stays readable without the directory while it's down, by the name it was sent with. + const projects = await unlessOffline(this.core.logger, () => + this.projects.byUris([ + ...new Set(rows.flatMap((row) => [row.projectUri, row.resultProjectUri]).filter((uri): uri is string => !!uri)) + ]) + ); return rows.map((row) => { - const project = row.projectUri ? projects.get(row.projectUri) : undefined; - const result = row.resultProjectUri ? projects.get(row.resultProjectUri) : undefined; + const project = row.projectUri ? projects?.get(row.projectUri) : undefined; + const result = row.resultProjectUri ? projects?.get(row.resultProjectUri) : undefined; const record = storedRecord(row); return { diff --git a/apps/web/src/lib/ui/DateBlock.svelte b/apps/web/src/lib/ui/DateBlock.svelte index 31682b5f1..88c3cc12b 100644 --- a/apps/web/src/lib/ui/DateBlock.svelte +++ b/apps/web/src/lib/ui/DateBlock.svelte @@ -1,16 +1,20 @@ -