Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Raidy is a raid scheduling project for Discord statics: the web console is where everyone plans, and Discord carries the announcements. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL.

**Status:** `v0.0.13` is an early development snapshot.
**Status:** `v0.0.14` is an early development snapshot.

```text
backend/ Go service, Discord interactions, web API, PostgreSQL migrations
Expand All @@ -22,6 +22,7 @@ deployment/ local PostgreSQL and Kubernetes deployment manifests
- Polls become read-only history when their final occurrence starts.
- Discord is announcement-only: each period is posted with its dates in every reader's own timezone, live availability counts and status, and an **Open in Raidy** link. The post is edited in place as votes and decisions change.
- `/raidy` replies privately with a link to your team's page.
- Raid times on the web show in each viewer's own timezone, with the team's time beneath when it differs; the schedule editor stays in the team's timezone.
- The responsive console supports persisted light and dark themes.

## Run locally
Expand Down
32 changes: 32 additions & 0 deletions web/e2e/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ test("raid times use a 24-hour clock", async ({ page }) => {
await expect(header).not.toContainText(/AM|PM/);
});

test.describe("a viewer in Los Angeles", () => {
test.use({ timezoneId: "America/Los_Angeles" });

test("sees raid times in their own timezone with the team's time beneath", async ({ page }) => {
await mockSignedInLeader(page);
await page.goto("/app");
const header = page.getByRole("columnheader").nth(1);
await expect(header).toContainText("Wed 7");
await expect(header).toContainText("05:00");
await expect(header).toContainText("21:00 Tokyo");
});
});

test.describe("a viewer in Auckland", () => {
test.use({ timezoneId: "Pacific/Auckland" });

test("sees the team's day when the raid falls on a different local day", async ({ page }) => {
await mockSignedInLeader(page);
await page.goto("/app");
const header = page.getByRole("columnheader").nth(1);
await expect(header).toContainText("Thu 8");
await expect(header).toContainText("01:00");
await expect(header).toContainText("Wed 7 21:00 Tokyo");
});
});

test("the team's time is not repeated for viewers in the team's timezone", async ({ page }) => {
await mockSignedInLeader(page);
await page.goto("/app");
await expect(page.getByRole("columnheader").nth(1)).not.toContainText("Tokyo");
});

test("a member edits and saves their availability on the web", async ({ page }) => {
const recorder = await mockSignedInLeader(page);
await page.goto("/app");
Expand Down
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "raidy-web",
"private": true,
"version": "0.0.13",
"version": "0.0.14",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 2 additions & 0 deletions web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export default defineConfig({
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: "retain-on-failure",
// The fixtures' team is in Tokyo; individual tests move the viewer elsewhere.
timezoneId: "Asia/Tokyo",
launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined }
},
projects: [
Expand Down
1 change: 1 addition & 0 deletions web/src/features/landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function Landing({
<figure aria-label="Example availability grid">
<AvailabilityGrid
poll={samplePoll}
viewerTimezone={samplePoll.timezone}
members={sampleMembers}
canManage={false}
busyID={null}
Expand Down
37 changes: 31 additions & 6 deletions web/src/features/overview/availability-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type Member, type Occurrence, type Poll } from "@/api";
import { Avatar, StatusChip } from "@/components/common";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { formatDay, formatTime } from "@/lib/format";
import { browserTimezone, formatDay, formatTime, teamTime } from "@/lib/format";
import { cn } from "@/lib/utils";

export type OccurrenceAction = "confirm" | "cancel" | "reopen";
Expand Down Expand Up @@ -77,6 +77,27 @@ function ActionsMenu({
);
}

function TeamTime({
value,
teamTimezone,
viewerTimezone
}: {
value: string;
teamTimezone: string;
viewerTimezone: string;
}) {
const label = teamTime(value, teamTimezone, viewerTimezone);
if (!label) return null;
return (
<div
className="mt-0.5 text-[11px] whitespace-nowrap text-muted-foreground/80"
title={`Team time (${teamTimezone})`}
>
{label}
</div>
);
}

export function AvailabilityGrid({
poll,
members,
Expand All @@ -86,7 +107,8 @@ export function AvailabilityGrid({
onAction,
currentUserID,
draft,
onToggle
onToggle,
viewerTimezone = browserTimezone()
}: {
poll: Poll;
members: Member[];
Expand All @@ -98,6 +120,8 @@ export function AvailabilityGrid({
/** Dates the current user marks available while editing; null when not editing. */
draft?: Set<number> | null;
onToggle?: (occurrenceID: number) => void;
/** Times show in the viewer's timezone, with the team's time beneath when it differs. */
viewerTimezone?: string;
}) {
const occurrences = poll.occurrences;
const memberIDs = Array.from(
Expand Down Expand Up @@ -128,10 +152,11 @@ export function AvailabilityGrid({
scope="col"
className="min-w-20 px-2 py-3 text-center align-top font-normal sm:min-w-24"
>
<div className="font-medium">{formatDay(occurrence.startsAt, poll.timezone)}</div>
<div className="font-medium">{formatDay(occurrence.startsAt, viewerTimezone)}</div>
<div className="font-mono text-xs text-muted-foreground">
{formatTime(occurrence.startsAt, poll.timezone)}
{formatTime(occurrence.startsAt, viewerTimezone)}
</div>
<TeamTime value={occurrence.startsAt} teamTimezone={poll.timezone} viewerTimezone={viewerTimezone} />
</th>
))}
</tr>
Expand All @@ -152,7 +177,7 @@ export function AvailabilityGrid({
<button
type="button"
aria-pressed={draft.has(occurrence.id)}
aria-label={`Available on ${formatDay(occurrence.startsAt, poll.timezone)}`}
aria-label={`Available on ${formatDay(occurrence.startsAt, viewerTimezone)}`}
className="rounded ring-2 ring-primary/40 focus-visible:ring-primary"
onClick={() => onToggle?.(occurrence.id!)}
>
Expand Down Expand Up @@ -192,7 +217,7 @@ export function AvailabilityGrid({
{canManage && occurrence.id && new Date(occurrence.startsAt) > now && (
<ActionsMenu
occurrence={occurrence}
label={formatDay(occurrence.startsAt, poll.timezone)}
label={formatDay(occurrence.startsAt, viewerTimezone)}
busy={busyID === occurrence.id}
onAction={(action) => onAction(occurrence, action)}
/>
Expand Down
6 changes: 3 additions & 3 deletions web/src/features/overview/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { LoadingPanel, PageHeader } from "@/components/common";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AvailabilityGrid, type OccurrenceAction } from "@/features/overview/availability-grid";
import { describeSchedule, formatDateTime, formatDay, formatPeriod, message } from "@/lib/format";
import { browserTimezone, describeSchedule, formatDateTime, formatDay, formatPeriod, message } from "@/lib/format";

const actionMessages: Record<OccurrenceAction, string> = {
confirm: "confirmed",
Expand Down Expand Up @@ -58,7 +58,7 @@ export function Overview({
setBusyID(occurrence.id);
try {
await api(`/api/occurrences/${occurrence.id}/status`, { method: "POST", body: JSON.stringify({ action }) });
toast.success(`${formatDay(occurrence.startsAt, poll.timezone)} ${actionMessages[action]}`);
toast.success(`${formatDay(occurrence.startsAt, browserTimezone())} ${actionMessages[action]}`);
setReload((value) => value + 1);
} catch (reason) {
toast.error(message(reason));
Expand Down Expand Up @@ -130,7 +130,7 @@ export function Overview({

const summary = `${describeSchedule(schedule)} · ${schedule.timezone}`;
const nextPost = schedule.enabled
? `Next timetable posts ${formatDateTime(schedule.nextPublishAt, schedule.timezone)}`
? `Next timetable posts ${formatDateTime(schedule.nextPublishAt, browserTimezone())}`
: "Automatic posting is paused";

if (!poll) {
Expand Down
19 changes: 18 additions & 1 deletion web/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function message(reason: unknown) {

const weekdayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

/** "Wed 14" in the team's timezone. */
/** "Wed 14" in the given timezone. */
export function formatDay(value: string, timezone: string) {
const parts = new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
Expand All @@ -69,6 +69,23 @@ export function formatDay(value: string, timezone: string) {
return `${part("weekday")} ${part("day")}`;
}

/** "Tokyo" for "Asia/Tokyo". */
export function timezoneCity(timezone: string) {
return (timezone.split("/").pop() ?? timezone).replace(/_/g, " ");
}

/**
* The team's wall-clock time for an instant, or null when the viewer already
* sees the same thing. The day is included only when it differs.
*/
export function teamTime(value: string, teamTimezone: string, viewerTimezone: string) {
const time = formatTime(value, teamTimezone);
const day = formatDay(value, teamTimezone);
if (time === formatTime(value, viewerTimezone) && day === formatDay(value, viewerTimezone)) return null;
const sameDay = day === formatDay(value, viewerTimezone);
return `${sameDay ? "" : `${day} `}${time} ${timezoneCity(teamTimezone)}`;
}

/** "Oct 12 – 18" for an inclusive YYYY-MM-DD range. */
export function formatPeriod(start: string, end: string) {
const from = parseLocalDate(start);
Expand Down
Loading