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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ secret.env
/backend/coverage.out
/web/node_modules/
/web/dist/
/web/dist-demo/
2 changes: 1 addition & 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.15` is an early development snapshot.
**Status:** `v0.0.16` is an early development snapshot.

```text
backend/ Go service, Discord interactions, web API, PostgreSQL migrations
Expand Down
68 changes: 68 additions & 0 deletions web/e2e/demo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, test, type Page } from "@playwright/test";

// Monday 09:00 in Tokyo: this week's raids are Wed 4, Thu 5 and Fri 6.
const monday = new Date("2026-11-02T00:00:00Z");

async function openDemo(page: Page) {
const requests: string[] = [];
// The demo must never reach a server.
await page.route("**/api/**", (route) => {
requests.push(route.request().url());
return route.abort();
});
await page.clock.setFixedTime(monday);
await page.goto("/demo/");
await expect(page.getByRole("table", { name: /availability/i })).toBeVisible();
return requests;
}

const counts = (page: Page) => page.getByTestId("available-count");

test("opens signed in on sample data without any server", async ({ page }) => {
const requests = await openDemo(page);
await expect(page.getByRole("note")).toContainText("Sample data");
await expect(page.getByRole("link", { name: "Sign in with Discord" })).toHaveCount(0);
const grid = page.getByRole("table", { name: /availability/i });
await expect(grid.getByRole("rowheader").first()).toContainText("You");
await expect(counts(page)).toHaveText(["8/8 — everyone is available", "7/8", "5/8"]);
await expect(page).toHaveURL(/\/demo\/\?team=1$/);
expect(requests).toEqual([]);
});

test("a vote updates the counts and flags the confirmed date", async ({ page }) => {
await openDemo(page);
await page.getByRole("button", { name: "Edit my availability" }).click();
await page.getByRole("button", { name: "Available on Wed 4" }).click();
await page.getByRole("button", { name: "Save availability" }).click();
await expect(page.getByText("Availability saved")).toBeVisible();
await expect(counts(page).first()).toHaveText("7/8");
await expect(page.getByText("Needs attention")).toBeVisible();
});

test("the leader cancels a date", async ({ page }) => {
await openDemo(page);
await page.getByRole("button", { name: "Actions for Fri 6" }).click();
await page.getByRole("button", { name: "Cancel date" }).click();
await expect(page.getByText("Cancelled", { exact: true })).toBeVisible();
});

test("a new raid time moves the open week's dates", async ({ page }) => {
await openDemo(page);
await page.getByRole("button", { name: "Schedule" }).click();
await page.getByRole("combobox", { name: "Start time" }).click();
await page.getByRole("option", { name: "20:00" }).click();
await page.getByRole("button", { name: "Save schedule" }).click();
await expect(page.getByText("Schedule saved")).toBeVisible();
await page.getByRole("button", { name: "Availability" }).click();
await expect(page.getByRole("columnheader").nth(1)).toContainText("20:00");
});

test("reloading starts over from the sample data", async ({ page }) => {
await openDemo(page);
await page.getByRole("button", { name: "Actions for Fri 6" }).click();
await page.getByRole("button", { name: "Cancel date" }).click();
await expect(page.getByText("Cancelled", { exact: true })).toBeVisible();
await page.reload();
await expect(counts(page)).toHaveText(["8/8 — everyone is available", "7/8", "5/8"]);
await expect(page.getByText("Cancelled", { exact: true })).toHaveCount(0);
});
1 change: 1 addition & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fafaf9" />
<meta name="description" content="Raidy static-team scheduling for Discord" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Raidy</title>
<script>
(() => {
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.

5 changes: 3 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
{
"name": "raidy-web",
"private": true,
"version": "0.0.15",
"version": "0.0.16",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"check": "tsc -b --pretty false",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"build:demo": "tsc -b && vite build --mode demo --base /demo/ --outDir dist-demo"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
Expand Down
28 changes: 21 additions & 7 deletions web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig, devices } from "@playwright/test";

const port = 5179;
const demoPort = 5180;

export default defineConfig({
testDir: "e2e",
Expand All @@ -14,12 +15,25 @@ export default defineConfig({
launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined }
},
projects: [
{ name: "desktop", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["Pixel 7"] } }
{ name: "desktop", testIgnore: "demo.spec.ts", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", testIgnore: "demo.spec.ts", use: { ...devices["Pixel 7"] } },
// The demo build runs the real app against in-browser sample data.
{
name: "demo",
testMatch: "demo.spec.ts",
use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${demoPort}` }
}
],
webServer: {
command: `npx vite --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: !process.env.CI
}
webServer: [
{
command: `npx vite --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: !process.env.CI
},
{
command: `npx vite --mode demo --base /demo/ --host 127.0.0.1 --port ${demoPort} --strictPort`,
url: `http://127.0.0.1:${demoPort}/demo/`,
reuseExistingServer: !process.env.CI
}
]
});
1 change: 1 addition & 0 deletions web/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 20 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Overview } from "@/features/overview/overview";
import { SchedulePanel } from "@/features/schedule/schedule-panel";
import { TeamPanel } from "@/features/team/team-panel";
import { message } from "@/lib/format";
import { isDemo, teamURL } from "@/lib/paths";
import { applyTheme, type Theme, type ThemePreference } from "@/lib/theme";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -47,7 +48,7 @@ export default function App() {
const wanted = preferred || teamID || fromURL;
const selected = values.find((team) => team.id === wanted)?.id ?? values[0]?.id ?? null;
setTeamID(selected);
if (selected) history.replaceState(null, "", `/app?team=${selected}`);
if (selected) history.replaceState(null, "", teamURL(selected));
}

useEffect(() => {
Expand Down Expand Up @@ -85,7 +86,7 @@ export default function App() {
setTeamID(id);
setCreating(false);
setTab("overview");
history.replaceState(null, "", `/app?team=${id}`);
history.replaceState(null, "", teamURL(id));
}

async function changed(id?: number) {
Expand Down Expand Up @@ -122,6 +123,7 @@ export default function App() {

return (
<div className="min-h-screen">
{isDemo && <DemoBanner />}
<header className="border-b bg-background">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6">
<Wordmark className="text-base" />
Expand Down Expand Up @@ -222,6 +224,22 @@ export default function App() {
);
}

function DemoBanner() {
return (
<div className="border-b bg-primary/10 text-sm" role="note">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-1 px-4 py-2 sm:px-6">
<span>
<span className="font-medium">Demo</span>
<span className="text-muted-foreground"> · Sample data. Changes reset when you reload.</span>
</span>
<a href="/" className="font-medium text-primary underline-offset-4 hover:underline">
About Raidy
</a>
</div>
</div>
);
}

function NoServers({ inviteUrl }: { inviteUrl: string }) {
return (
<div className="max-w-lg">
Expand Down
7 changes: 7 additions & 0 deletions web/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isDemo } from "@/lib/paths";

export class ApiError extends Error {
constructor(
public status: number,
Expand All @@ -8,6 +10,11 @@ export class ApiError extends Error {
}

export async function api<T>(path: string, options: RequestInit = {}): Promise<T | null> {
if (isDemo) {
// The demo runs entirely in the browser against sample data.
const { demoApi } = await import("./demo/backend");
return (await demoApi(path, options)) as T | null;
}
const headers = new Headers(options.headers);
if (options.body) headers.set("Content-Type", "application/json");
let response: Response;
Expand Down
Loading
Loading