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: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ jobs:
- name: Build web console
working-directory: web
run: npm run build
- name: Build product page and demo
working-directory: web
run: npm run build:pages
- name: Install Playwright browser
working-directory: web
run: npx playwright install --with-deps chromium
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Pages

on:
push:
branches: [main]
paths: ["web/**", ".github/workflows/pages.yml"]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v6.0.0
with:
node-version: 24
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Capture app screenshots
run: npm run screenshots
- name: Build product page and demo
run: npm run build:pages
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v4
with:
path: web/dist-pages
- id: deployment
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ secret.env
/web/node_modules/
/web/dist/
/web/dist-demo/
/web/dist-pages/
6 changes: 5 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.17` is an early development snapshot.
**Status:** `v0.0.18` is an early development snapshot.

```text
backend/ Go service, Discord interactions, web API, PostgreSQL migrations
Expand Down Expand Up @@ -55,6 +55,10 @@ The Vite development server opens at `http://localhost:5173` and proxies API cal

The Go process applies pinned, embedded Goose migrations before opening the application store. Existing local databases are upgraded in place.

## Product page and demo

`web/product/` is the public product page, and `npm run build:demo` builds the real app against in-browser sample data. `npm run screenshots` captures the app screenshots the page shows, and `npm run build:pages` builds both into `web/dist-pages`. The Pages workflow deploys that on every merge to `main`.

## Run on local Kubernetes

The production image packages the Go service and built web console. The local Kustomize overlay runs it with a persistent PostgreSQL instance on k3d and exposes it through `kubectl port-forward`; see [deployment/README.md](deployment/README.md).
Expand Down
1 change: 1 addition & 0 deletions web/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ src/components/ui
test-results
playwright-report
dist-demo
dist-pages
31 changes: 31 additions & 0 deletions web/e2e/product.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { devices, expect, test } from "@playwright/test";

const monday = new Date("2026-11-02T00:00:00Z");

test("the product page leads to the live demo and the code", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("See which nights your whole static can raid.");
await expect(page.getByRole("link", { name: "Try the live demo" })).toHaveAttribute("href", "demo/");
await expect(page.getByRole("link", { name: "View on GitHub" }).first()).toHaveAttribute(
"href",
"https://github.com/joshchen-dev/raidy"
);
});

test("tapping a night in the hero updates the Discord post", async ({ page }) => {
await page.clock.setFixedTime(monday);
await page.goto("/");
const post = page.getByRole("figure", { name: "The Raidy post in Discord" });
await expect(post).toContainText("8/8 available · Confirmed");
await page.getByRole("button", { name: "Available on Wed 4" }).click();
await expect(post).toContainText("7/8 available · Attention required");
await expect(page.getByTestId("available-count").first()).toHaveText("7/8");
});

test("the page never scrolls sideways on a phone", async ({ browser }) => {
const page = await browser.newPage({ ...devices["Pixel 7"] });
await page.goto("http://127.0.0.1:5184/");
await expect(page.getByRole("figure", { name: "The Raidy post in Discord" })).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
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.

6 changes: 4 additions & 2 deletions 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.17",
"version": "0.0.18",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -10,7 +10,9 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:e2e": "playwright test",
"build:demo": "tsc -b && vite build --mode demo --base /demo/ --outDir dist-demo"
"build:demo": "tsc -b && vite build --mode demo --base /demo/ --outDir dist-demo",
"screenshots": "playwright test --config playwright.screenshots.config.ts",
"build:pages": "rm -rf dist-pages && tsc -b && vite build --config vite.product.config.ts && vite build --mode demo --base /demo/ --outDir dist-pages/demo"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
Expand Down
13 changes: 10 additions & 3 deletions web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";

const port = 5179;
const demoPort = 5180;
const productPort = 5184;

export default defineConfig({
testDir: "e2e",
Expand All @@ -15,14 +16,15 @@ export default defineConfig({
launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined }
},
projects: [
{ name: "desktop", testIgnore: "demo.spec.ts", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", testIgnore: "demo.spec.ts", use: { ...devices["Pixel 7"] } },
{ name: "desktop", testIgnore: /(demo|product).spec.ts/, use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", testIgnore: /(demo|product).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}` }
}
},
{ name: "product", testMatch: "product.spec.ts", use: { baseURL: `http://127.0.0.1:${productPort}` } }
],
webServer: [
{
Expand All @@ -34,6 +36,11 @@ export default defineConfig({
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
},
{
command: `npx vite --config vite.product.config.ts --host 127.0.0.1 --port ${productPort} --strictPort`,
url: `http://127.0.0.1:${productPort}/`,
reuseExistingServer: !process.env.CI
}
]
});
29 changes: 29 additions & 0 deletions web/playwright.screenshots.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";

// Captures the product page's screenshots from the running demo:
// npm run screenshots
const demoPort = 5181;
const productPort = 5182;

export default defineConfig({
testDir: "scripts",
testMatch: "screenshots.spec.ts",
workers: 1,
reporter: "list",
use: {
timezoneId: "Asia/Tokyo",
launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined }
},
webServer: [
{
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
},
{
command: `npx vite --config vite.product.config.ts --host 127.0.0.1 --port ${productPort} --strictPort`,
url: `http://127.0.0.1:${productPort}/`,
reuseExistingServer: !process.env.CI
}
]
});
2 changes: 2 additions & 0 deletions web/product/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
public/screens/
public/og.png
30 changes: 30 additions & 0 deletions web/product/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fafaf9" />
<title>Raidy: raid nights for Discord statics</title>
<meta
name="description"
content="Raidy finds the nights your whole raid team can make. Members vote on the web; one Discord post stays up to date."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Raidy" />
<meta property="og:description" content="Find the nights your whole static can raid. Try the live demo." />
<meta property="og:image" content="/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
(() => {
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", dark);
document.querySelector('meta[name="theme-color"]').content = dark ? "#121110" : "#fafaf9";
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
10 changes: 10 additions & 0 deletions web/product/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ProductPage } from "@/product/product-page";
import "./product.css";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<ProductPage />
</StrictMode>
);
4 changes: 4 additions & 0 deletions web/product/product.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@import "../src/styles.css";

/* The product page's root is product/, so point Tailwind at the shared sources. */
@source "../src";
1 change: 1 addition & 0 deletions web/product/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.
38 changes: 38 additions & 0 deletions web/scripts/screenshots.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { devices, expect, test, type Page } from "@playwright/test";

const demo = "http://127.0.0.1:5181/demo/";
const product = "http://127.0.0.1:5182/";
const out = "product/public";

async function openDemo(page: Page, theme: "light" | "dark") {
await page.emulateMedia({ colorScheme: theme });
await page.goto(demo);
// The first load can wait on Vite optimizing dependencies.
await expect(page.getByRole("table", { name: /availability/i })).toBeVisible({ timeout: 30_000 });
// The demo banner is noise on the product page.
await page.getByRole("note").evaluate((banner) => banner.remove());
}

for (const theme of ["light", "dark"] as const) {
test(`schedule editor, ${theme}`, async ({ browser }) => {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 });
await openDemo(page, theme);
await page.getByRole("button", { name: "Schedule" }).click();
await expect(page.getByRole("complementary", { name: "Preview" }).getByRole("listitem").first()).toBeVisible();
await page.screenshot({ path: `${out}/screens/schedule-${theme}.png` });
});

test(`availability on a phone, ${theme}`, async ({ browser }) => {
const page = await browser.newPage({ ...devices["Pixel 7"] });
await openDemo(page, theme);
await page.screenshot({ path: `${out}/screens/mobile-${theme}.png` });
});
}

test("link preview image", async ({ browser }) => {
const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
await page.emulateMedia({ colorScheme: "light" });
await page.goto(product);
await expect(page.getByRole("figure", { name: "The Raidy post in Discord" })).toBeVisible();
await page.screenshot({ path: `${out}/og.png` });
});
Loading
Loading