| null = null;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.loadNews();
+ }
+
+ private async loadNews() {
+ try {
+ const allItems = await getNews();
+ // Reset stale dismissed list when all items would be hidden
+ const visible = getVisibleNewsItems(allItems);
+ if (visible.length === 0 && allItems.length > 0) {
+ localStorage.removeItem(DISMISSED_NEWS_KEY);
+ this.items = allItems;
+ } else {
+ this.items = visible;
+ }
+ this.startCycle();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.stopCycle();
+ }
+
+ private startCycle() {
+ this.stopCycle();
+ if (this.items.length > 1) {
+ this.cycleTimer = setInterval(() => {
+ this.activeIndex = (this.activeIndex + 1) % this.items.length;
+ }, CYCLE_INTERVAL_MS);
+ }
+ }
+
+ private stopCycle() {
+ if (this.cycleTimer !== null) {
+ clearInterval(this.cycleTimer);
+ this.cycleTimer = null;
+ }
+ }
+
+ private dismiss(id: string) {
+ const dismissed = getDismissedIds();
+ dismissed.add(id);
+ saveDismissedIds(dismissed);
+ this.items = this.items.filter((item) => item.id !== id);
+ if (this.activeIndex >= this.items.length) {
+ this.activeIndex = 0;
+ }
+ this.startCycle();
+ }
+
+ private goTo(index: number) {
+ this.activeIndex = index;
+ this.startCycle();
+ }
+
+ render() {
+ if (this.items.length === 0) return nothing;
+
+ const item = this.items[this.activeIndex];
+
+ return html`
+
+
+
${translateText(typeLabelKeys[item.type])}
+
+ ${item.url
+ ? html`
${item.title}`
+ : html`
${item.title}`}
+
${item.description}
+
+ ${this.items.length > 1
+ ? html`
+
+ ${this.items.map(
+ (_, i) => html`
+
+ `,
+ )}
+
+ `
+ : nothing}
+
+
+
+ `;
+ }
+}
diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts
index 9417b5fd2..00db01b27 100644
--- a/src/client/components/PlayPage.ts
+++ b/src/client/components/PlayPage.ts
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
+import "./NewsBox";
@customElement("play-page")
export class PlayPage extends LitElement {
@@ -11,7 +12,7 @@ export class PlayPage extends LitElement {
return html`
@@ -107,6 +108,9 @@ export class PlayPage extends LitElement {
class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)] lg:col-span-2 -mb-4"
>
+
+
+
;
+
+export const NewsItemSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ description: z.string(),
+ url: z.string().nullable().optional(),
+ type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
+});
+export type NewsItem = z.infer;
diff --git a/tests/client/components/NewsBox.test.ts b/tests/client/components/NewsBox.test.ts
new file mode 100644
index 000000000..6767beb7d
--- /dev/null
+++ b/tests/client/components/NewsBox.test.ts
@@ -0,0 +1,88 @@
+import newsItems from "../../../resources/news.json";
+import {
+ getVisibleNewsItems,
+ NewsItem,
+} from "../../../src/client/components/NewsBox";
+
+const DISMISSED_NEWS_KEY = "dismissedNewsItems";
+const allItems = newsItems as NewsItem[];
+
+function createMockLocalStorage(): Storage {
+ let store: Record = {};
+ return {
+ getItem: (key: string) => store[key] ?? null,
+ setItem: (key: string, value: string) => {
+ store[key] = String(value);
+ },
+ removeItem: (key: string) => {
+ delete store[key];
+ },
+ clear: () => {
+ store = {};
+ },
+ get length() {
+ return Object.keys(store).length;
+ },
+ key: (index: number) => Object.keys(store)[index] ?? null,
+ };
+}
+
+describe("NewsBox", () => {
+ beforeEach(() => {
+ vi.stubGlobal("localStorage", createMockLocalStorage());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe("getVisibleNewsItems", () => {
+ it("returns all items when none are dismissed", () => {
+ const items = getVisibleNewsItems(allItems);
+ expect(items.length).toBe(newsItems.length);
+ });
+
+ it("filters out dismissed items", () => {
+ const items = getVisibleNewsItems(allItems);
+ const firstId = items[0].id;
+ localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([firstId]));
+ const filtered = getVisibleNewsItems(allItems);
+ expect(filtered.find((i) => i.id === firstId)).toBeUndefined();
+ expect(filtered.length).toBe(items.length - 1);
+ });
+
+ it("returns empty when all items are dismissed", () => {
+ const allIds = allItems.map((i) => i.id);
+ localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify(allIds));
+ const items = getVisibleNewsItems(allItems);
+ expect(items.length).toBe(0);
+ });
+ });
+
+ describe("news items structure", () => {
+ it("each item has required fields", () => {
+ const items = getVisibleNewsItems(allItems);
+ for (const item of items) {
+ expect(item.id).toBeDefined();
+ expect(typeof item.id).toBe("string");
+ expect(item.title).toBeDefined();
+ expect(typeof item.title).toBe("string");
+ expect(item.description).toBeDefined();
+ expect(typeof item.description).toBe("string");
+ expect(item.type).toBeDefined();
+ expect(["tournament", "tutorial", "announcement"]).toContain(item.type);
+ }
+ });
+
+ it("each item has a unique id", () => {
+ const items = getVisibleNewsItems(allItems);
+ const ids = items.map((i) => i.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("contains a tournament entry", () => {
+ const items = getVisibleNewsItems(allItems);
+ expect(items.some((i) => i.type === "tournament")).toBe(true);
+ });
+ });
+});