aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-08-03 17:37:22 -0700
committerPinapelz <yukais@pinapelz.com>2026-08-03 17:40:15 -0700
commitb37a7ffc5b19f7330050970ebac7f949a89812a2 (patch)
treea7c194528273721902874c9e71f3245934c0168d
parent1f52e962086856ddd6981139e2d4aca20201e9cb (diff)
feat: group streaks
- maintain completion streaks as a group - join via a provided code, streaks increment when you guess correctly
-rw-r--r--server/daily.ts49
-rw-r--r--server/db/groups.ts363
-rw-r--r--server/db/schema.ts2
-rw-r--r--server/group.ts99
-rw-r--r--server/index.ts4
-rw-r--r--src/components/Result/index.styled.ts33
-rw-r--r--src/components/Result/index.tsx61
-rw-r--r--src/helpers/fetchSolution.ts2
-rw-r--r--src/helpers/group.ts141
-rw-r--r--src/hooks/useGameState.ts9
-rw-r--r--src/pages/LandingPage.tsx207
11 files changed, 949 insertions, 21 deletions
diff --git a/server/daily.ts b/server/daily.ts
index f3eb9d8..6a9a83c 100644
--- a/server/daily.ts
+++ b/server/daily.ts
@@ -13,6 +13,7 @@ import {
getDailySong,
getDailyMusicVideo,
} from "./shared";
+import { recordDailySolve, recordMvSolve } from "./db/groups";
export const dailyRouter = Router();
@@ -69,6 +70,8 @@ dailyRouter.post("/guess", guessLimiter, (req, res) => {
state?: unknown;
sig?: unknown;
guess?: unknown;
+ username?: unknown;
+ groupId?: unknown;
};
if (!verifySessionToken(body.sessionToken, today)) {
@@ -135,6 +138,28 @@ dailyRouter.post("/guess", guessLimiter, (req, res) => {
guesses: [...state.guesses, nextGuess],
};
+ const hasFinishedRound = nextState.didGuess || nextState.currentTry >= 6;
+
+ if (
+ hasFinishedRound &&
+ typeof body.groupId === "string" &&
+ body.groupId.trim().length > 0 &&
+ typeof body.username === "string" &&
+ body.username.trim().length > 0
+ ) {
+ try {
+ recordDailySolve(
+ body.groupId,
+ body.username,
+ today,
+ nextState.currentTry,
+ nextState.didGuess
+ );
+ } catch (error) {
+ console.error("Failed to record group daily solve", error);
+ }
+ }
+
res.json({
state: nextState,
sig: signState(nextState),
@@ -149,6 +174,8 @@ dailyRouter.post("/guessMV", guessLimiter, (req, res) => {
state?: unknown;
sig?: unknown;
guess?: unknown;
+ username?: unknown;
+ groupId?: unknown;
};
if (!verifySessionToken(body.sessionToken, today)) {
@@ -215,6 +242,28 @@ dailyRouter.post("/guessMV", guessLimiter, (req, res) => {
guesses: [...state.guesses, nextGuess],
};
+ const hasFinishedRound = nextState.didGuess || nextState.currentTry >= 6;
+
+ if (
+ hasFinishedRound &&
+ typeof body.groupId === "string" &&
+ body.groupId.trim().length > 0 &&
+ typeof body.username === "string" &&
+ body.username.trim().length > 0
+ ) {
+ try {
+ recordMvSolve(
+ body.groupId,
+ body.username,
+ today,
+ nextState.currentTry,
+ nextState.didGuess
+ );
+ } catch (error) {
+ console.error("Failed to record group MV solve", error);
+ }
+ }
+
res.json({
state: nextState,
sig: signState(nextState),
diff --git a/server/db/groups.ts b/server/db/groups.ts
index 1060501..12e5362 100644
--- a/server/db/groups.ts
+++ b/server/db/groups.ts
@@ -1,27 +1,350 @@
-import { db } from "./index";
import crypto from "node:crypto";
+import { getUtcDate } from "../shared";
+import { db } from "./index";
-export function createGroup(name: string) {
- const id = crypto.randomUUID();
- const joinCode = crypto
- .randomBytes(3)
- .toString("hex")
- .toUpperCase();
+type GroupMode = "daily" | "mv";
+
+function previousUtcDate(date: string): string {
+ const value = new Date(`${date}T00:00:00Z`);
+ value.setUTCDate(value.getUTCDate() - 1);
+ return value.toISOString().slice(0, 10);
+}
- db.prepare(`
- INSERT INTO groups
- (id, name, join_code, created_at)
- VALUES (?, ?, ?, ?)
- `).run(
- id,
- name,
- joinCode,
- Date.now()
- );
+function getSolveTableName(mode: GroupMode): "daily_solves" | "mv_solves" {
+ return mode === "mv" ? "mv_solves" : "daily_solves";
+}
+function getStreakColumns(mode: GroupMode): {
+ currentStreak: "current_streak" | "mv_current_streak";
+ lastCompleted: "last_completed" | "mv_last_completed";
+} {
+ if (mode === "mv") {
return {
- id,
- name,
- joinCode
+ currentStreak: "mv_current_streak",
+ lastCompleted: "mv_last_completed",
};
+ }
+
+ return {
+ currentStreak: "current_streak",
+ lastCompleted: "last_completed",
+ };
+}
+
+function isGroupSolvedOnDate(groupId: string, date: string, mode: GroupMode): boolean {
+ const tableName = getSolveTableName(mode);
+ const solvedCount = db
+ .prepare(
+ `
+ SELECT COUNT(DISTINCT username) as count
+ FROM ${tableName}
+ WHERE group_id = ? AND date = ?
+ AND solved = 1
+ `
+ )
+ .get(groupId, date) as { count: number };
+
+ return !!solvedCount && solvedCount.count >= 1;
+}
+
+function getStoredGroupStreak(
+ groupId: string,
+ mode: GroupMode
+): {
+ currentStreak: number;
+ lastCompleted: string | null;
+} {
+ const columns = getStreakColumns(mode);
+ const row = db
+ .prepare(
+ `
+ SELECT ${columns.currentStreak} as current_streak, ${columns.lastCompleted} as last_completed
+ FROM groups
+ WHERE id = ?
+ `
+ )
+ .get(groupId) as
+ | { current_streak: number; last_completed: string | null }
+ | undefined;
+
+ if (!row) {
+ return { currentStreak: 0, lastCompleted: null };
+ }
+
+ return {
+ currentStreak: row.current_streak,
+ lastCompleted: row.last_completed,
+ };
+}
+
+function getActiveGroupStreak(
+ groupId: string,
+ mode: GroupMode,
+ today = getUtcDate()
+): {
+ currentStreak: number;
+ lastCompleted: string | null;
+} {
+ const stored = getStoredGroupStreak(groupId, mode);
+ const columns = getStreakColumns(mode);
+
+ if (!stored.lastCompleted) {
+ if (stored.currentStreak !== 0) {
+ db.prepare(
+ `
+ UPDATE groups
+ SET ${columns.currentStreak} = 0
+ WHERE id = ?
+ `
+ ).run(groupId);
+ }
+
+ return { currentStreak: 0, lastCompleted: null };
+ }
+
+ const yesterday = previousUtcDate(today);
+ if (stored.lastCompleted === today || stored.lastCompleted === yesterday) {
+ return stored;
+ }
+
+ if (stored.currentStreak !== 0) {
+ db.prepare(
+ `
+ UPDATE groups
+ SET ${columns.currentStreak} = 0
+ WHERE id = ?
+ `
+ ).run(groupId);
+ }
+
+ return {
+ currentStreak: 0,
+ lastCompleted: stored.lastCompleted,
+ };
+}
+
+function updateGroupStreakForToday(
+ groupId: string,
+ mode: GroupMode,
+ today = getUtcDate()
+): {
+ currentStreak: number;
+ lastCompleted: string | null;
+} {
+ const stored = getActiveGroupStreak(groupId, mode, today);
+ const columns = getStreakColumns(mode);
+
+ if (!isGroupSolvedOnDate(groupId, today, mode)) {
+ return stored;
+ }
+
+ if (stored.lastCompleted === today) {
+ return stored;
+ }
+
+ const yesterday = previousUtcDate(today);
+ const nextStreak = stored.lastCompleted === yesterday ? stored.currentStreak + 1 : 1;
+
+ db.prepare(
+ `
+ UPDATE groups
+ SET ${columns.currentStreak} = ?, ${columns.lastCompleted} = ?
+ WHERE id = ?
+ `
+ ).run(nextStreak, today, groupId);
+
+ return {
+ currentStreak: nextStreak,
+ lastCompleted: today,
+ };
+}
+
+export function createGroup(name: string) {
+ const normalizedName = name.trim().slice(0, 64);
+ if (!normalizedName) {
+ throw new Error("Group name is required");
+ }
+
+ const existingGroup = db
+ .prepare(
+ `
+ SELECT id FROM groups WHERE name = ?
+ `
+ )
+ .get(normalizedName) as { id: string } | undefined;
+
+ if (existingGroup) {
+ throw new Error("Group already exists");
+ }
+
+ const id = crypto.randomUUID();
+ const joinCode = crypto.randomBytes(3).toString("hex").toUpperCase();
+
+ db.prepare(
+ `
+ INSERT INTO groups (id, name, join_code, created_at)
+ VALUES (?, ?, ?, ?)
+ `
+ ).run(id, normalizedName, joinCode, Date.now());
+
+ return {
+ id,
+ name: normalizedName,
+ joinCode,
+ };
+}
+
+export function getGroupByJoinCode(
+ joinCode: string
+): { id: string; name: string; joinCode: string } | null {
+ const normalizedCode = joinCode.trim().toUpperCase();
+
+ const group = db
+ .prepare(
+ `
+ SELECT id, name, join_code
+ FROM groups
+ WHERE join_code = ?
+ `
+ )
+ .get(normalizedCode) as
+ | { id: string; name: string; join_code: string }
+ | undefined;
+
+ if (!group) return null;
+
+ return {
+ id: group.id,
+ name: group.name,
+ joinCode: group.join_code,
+ };
+}
+
+export function recordGroupJoin(groupId: string, username: string) {
+ const normalizedUsername = username.trim().slice(0, 32);
+ if (!normalizedUsername) {
+ throw new Error("Username is required");
+ }
+
+ // Check if the user is already a member of the group
+ const existingJoin = db
+ .prepare(
+ `
+ SELECT group_id
+ FROM group_members
+ WHERE group_id = ? AND username = ?
+ `
+ )
+ .get(groupId, normalizedUsername) as { group_id: string } | undefined;
+ if (existingJoin) return;
+
+ // add the user to the group
+ db.prepare(
+ `
+ INSERT INTO group_members (group_id, username, joined_at)
+ VALUES (?, ?, ?)
+ `
+ ).run(groupId, normalizedUsername, Date.now());
+}
+
+function recordSolve(
+ tableName: "daily_solves" | "mv_solves",
+ groupId: string,
+ username: string,
+ date: string,
+ attempt: number,
+ guessed: boolean
+) {
+ const today = getUtcDate();
+ if (date !== today) {
+ throw new Error(`Only today's solves can be recorded. Expected ${today}, got ${date}.`);
+ }
+
+ const normalizedUsername = username.trim().slice(0, 32);
+ const membership = db
+ .prepare(
+ `
+ SELECT group_id
+ FROM group_members
+ WHERE group_id = ? AND username = ?
+ `
+ )
+ .get(groupId, normalizedUsername) as { group_id: string } | undefined;
+
+ if (!membership) return;
+
+ const boundedAttempt = Math.max(1, Math.min(6, Math.floor(attempt)));
+ db.prepare(
+ `
+ INSERT INTO ${tableName} (group_id, username, date, solved, attempts)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(group_id, username, date)
+ DO UPDATE SET solved = excluded.solved, attempts = excluded.attempts
+ `
+ ).run(groupId, normalizedUsername, date, guessed ? 1 : 0, boundedAttempt);
+
+ const mode: GroupMode = tableName === "mv_solves" ? "mv" : "daily";
+ updateGroupStreakForToday(groupId, mode, today);
+}
+
+export function recordDailySolve(
+ groupId: string,
+ username: string,
+ date: string,
+ attempt: number,
+ guessed: boolean
+) {
+ recordSolve("daily_solves", groupId, username, date, attempt, guessed);
+}
+
+export function recordMvSolve(
+ groupId: string,
+ username: string,
+ date: string,
+ attempt: number,
+ guessed: boolean
+) {
+ recordSolve("mv_solves", groupId, username, date, attempt, guessed);
+}
+
+export function getGroupDailyStatus(
+ groupId: string,
+ date: string,
+ mode: GroupMode = "daily"
+): {
+ groupId: string;
+ groupName: string;
+ currentStreak: number;
+ finishedUsers: string[];
+} | null {
+ const group = db
+ .prepare(
+ `
+ SELECT id, name
+ FROM groups
+ WHERE id = ?
+ `
+ )
+ .get(groupId) as { id: string; name: string } | undefined;
+ if (!group) return null;
+
+ const streak = getActiveGroupStreak(groupId, mode, date);
+ const solveTable = getSolveTableName(mode);
+ const finishedRows = db
+ .prepare(
+ `
+ SELECT username
+ FROM ${solveTable}
+ WHERE group_id = ? AND date = ?
+ AND (solved = 1 OR attempts >= 6)
+ ORDER BY username ASC
+ `
+ ).all(groupId, date) as Array<{ username: string }>;
+
+ return {
+ groupId: group.id,
+ groupName: group.name,
+ currentStreak: streak.currentStreak,
+ finishedUsers: finishedRows.map((row) => row.username),
+ };
}
diff --git a/server/db/schema.ts b/server/db/schema.ts
index 8fc2532..eca4dcc 100644
--- a/server/db/schema.ts
+++ b/server/db/schema.ts
@@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS groups (
join_code TEXT UNIQUE NOT NULL,
current_streak INTEGER NOT NULL DEFAULT 0,
last_completed TEXT,
+ mv_current_streak INTEGER NOT NULL DEFAULT 0,
+ mv_last_completed TEXT,
created_at INTEGER NOT NULL
);
diff --git a/server/group.ts b/server/group.ts
new file mode 100644
index 0000000..be71cc3
--- /dev/null
+++ b/server/group.ts
@@ -0,0 +1,99 @@
+import { Router } from "express";
+import {
+ createGroup,
+ getGroupDailyStatus,
+ getGroupByJoinCode,
+ recordGroupJoin,
+} from "./db/groups";
+import { getUtcDate } from "./shared";
+
+export const groupRouter = Router();
+
+groupRouter.post("/create-group", (req, res) => {
+ const body = req.body as {
+ name?: unknown;
+ username?: unknown;
+ };
+
+ if (typeof body.name !== "string" || body.name.trim().length === 0) {
+ res.status(400).json({ error: "Group name is required." });
+ return;
+ }
+
+ try {
+ const group = createGroup(body.name);
+
+ if (typeof body.username === "string" && body.username.trim()) {
+ recordGroupJoin(group.id, body.username);
+ }
+
+ res.json({
+ group: {
+ id: group.id,
+ name: group.name,
+ joinToken: group.joinCode,
+ },
+ });
+ } catch (error) {
+ res
+ .status(500)
+ .json({ error: "Group already exists, choose a different name" });
+ }
+});
+
+groupRouter.post("/join-group", (req, res) => {
+ const body = req.body as {
+ joinToken?: unknown;
+ username?: unknown;
+ };
+
+ // validate request
+ if (typeof body.joinToken !== "string" || body.joinToken.trim().length === 0) {
+ res.status(400).json({ error: "joinToken is required." });
+ return;
+ }
+ if (typeof body.username !== "string" || body.username.trim().length === 0) {
+ res.status(400).json({ error: "username is required." });
+ return;
+ }
+ const group = getGroupByJoinCode(body.joinToken);
+ if (!group) {
+ res.status(404).json({ error: "Group not found for that token." });
+ return;
+ }
+ try {
+ recordGroupJoin(group.id, body.username);
+ } catch {
+ res.status(500).json({ error: "Unable to join group." });
+ return;
+ }
+
+ res.json({
+ groupId: group.id,
+ groupName: group.name,
+ joinToken: group.joinCode,
+ username: body.username.trim(),
+ });
+});
+
+groupRouter.get("/group-status", (req, res) => {
+ const groupId = req.query.groupId;
+ const date = req.query.date;
+ const mode = req.query.mode;
+
+ if (typeof groupId !== "string" || groupId.trim().length === 0) {
+ res.status(400).json({ error: "groupId is required." });
+ return;
+ }
+
+ const targetDate = typeof date === "string" && date.trim() ? date : getUtcDate();
+ const normalizedMode = mode === "mv" ? "mv" : "daily";
+ const status = getGroupDailyStatus(groupId, targetDate, normalizedMode);
+
+ if (!status) {
+ res.status(404).json({ error: "Group not found." });
+ return;
+ }
+
+ res.json(status);
+});
diff --git a/server/index.ts b/server/index.ts
index b076de0..5f11652 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -3,7 +3,8 @@ import cors from "cors";
import dotenv from "dotenv";
import { dailyRouter } from "./daily";
import { selectRouter } from "./select";
-import "./db/schema"
+import { groupRouter } from "./group";
+import "./db/schema";
dotenv.config();
@@ -15,6 +16,7 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
app.use(dailyRouter);
app.use(selectRouter);
+app.use(groupRouter);
const enableDailyMusicVideo = process.env.ENABLE_DAILY_MUSIC_VIDEO === "true";
diff --git a/src/components/Result/index.styled.ts b/src/components/Result/index.styled.ts
index 3b2d33d..9b502f7 100644
--- a/src/components/Result/index.styled.ts
+++ b/src/components/Result/index.styled.ts
@@ -36,3 +36,36 @@ export const TimeToNext = styled.h4`
color: var(--cl-gray-6);
margin-top: 16px;
`;
+
+export const GroupStatus = styled.div`
+ margin-top: 14px;
+ padding: 10px 12px;
+ border: 1px solid var(--cl-gray-4);
+ border-radius: 6px;
+ width: 100%;
+ max-width: 560px;
+`;
+
+export const GroupHeading = styled.h4`
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: var(--cl-white);
+ margin: 0 0 6px 0;
+`;
+
+export const GroupMeta = styled.p`
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.8rem;
+ font-weight: 400;
+ color: var(--cl-gray-7);
+ margin: 0;
+
+ strong {
+ color: var(--cl-green-6);
+ }
+
+ & + & {
+ margin-top: 6px;
+ }
+`;
diff --git a/src/components/Result/index.tsx b/src/components/Result/index.tsx
index 94a59cf..3f35933 100644
--- a/src/components/Result/index.tsx
+++ b/src/components/Result/index.tsx
@@ -11,6 +11,11 @@ import { MiniYouTubePlayer } from "../MiniYouTubePlayer";
import * as Styled from "./index.styled";
import { theme } from "../../constants";
import GuessDistributionChart from '../Chart';
+import {
+ getGroupDailyStatus,
+ getStoredGroupMembership,
+ GroupDailyStatus,
+} from "../../helpers/group";
interface SolutionProps {
didGuess: boolean;
@@ -106,6 +111,7 @@ export function Result({
onPlayAgain,
}: Props) {
const [timeLeftStr, setTimeLeftStr] = useState<string>('');
+ const [groupStatus, setGroupStatus] = React.useState<GroupDailyStatus | null>(null);
React.useEffect(() => {
const updateTimeLeft = () => {
@@ -133,6 +139,37 @@ export function Result({
const isUnlimited = mode === "unlimited";
const chartMode = mode === "dailyMV" ? "dailyMV" : "daily";
+ React.useEffect(() => {
+ if (isUnlimited) {
+ setGroupStatus(null);
+ return;
+ }
+
+ const membership = getStoredGroupMembership();
+ if (!membership) {
+ setGroupStatus(null);
+ return;
+ }
+
+ let cancelled = false;
+
+ getGroupDailyStatus(membership.groupId, sessionDate, chartMode)
+ .then((status) => {
+ if (!cancelled) {
+ setGroupStatus(status);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setGroupStatus(null);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isUnlimited, sessionDate, chartMode]);
+
if (didGuess) {
const textForTry = ["Perfect!", "Wow!", "Super!", "Congrats!", "Nice!"];
@@ -157,6 +194,18 @@ export function Result({
{!isUnlimited && <ShareButton guesses={guesses} variant="green" />}
+ {groupStatus && (
+ <Styled.GroupStatus>
+ <Styled.GroupHeading>{groupStatus.groupName}</Styled.GroupHeading>
+ <Styled.GroupMeta>
+ Group streak: <strong>{groupStatus.currentStreak}</strong>
+ </Styled.GroupMeta>
+ <Styled.GroupMeta>
+ Finished today: {groupStatus.finishedUsers.length > 0 ? groupStatus.finishedUsers.join(", ") : "No one yet"}
+ </Styled.GroupMeta>
+ </Styled.GroupStatus>
+ )}
+
{isUnlimited && onPlayAgain ? (
<Button variant="green" onClick={onPlayAgain}>
Play Again
@@ -191,6 +240,18 @@ export function Result({
{!isUnlimited && <ShareButton guesses={guesses} variant="red" />}
+ {groupStatus && (
+ <Styled.GroupStatus>
+ <Styled.GroupHeading>{groupStatus.groupName}</Styled.GroupHeading>
+ <Styled.GroupMeta>
+ Group streak: <strong>{groupStatus.currentStreak}</strong>
+ </Styled.GroupMeta>
+ <Styled.GroupMeta>
+ Finished today: {groupStatus.finishedUsers.length > 0 ? groupStatus.finishedUsers.join(", ") : "No one yet"}
+ </Styled.GroupMeta>
+ </Styled.GroupStatus>
+ )}
+
{isUnlimited && onPlayAgain ? (
<Button variant="red" onClick={onPlayAgain}>
Play Again
diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts
index b7895bb..a91932a 100644
--- a/src/helpers/fetchSolution.ts
+++ b/src/helpers/fetchSolution.ts
@@ -55,6 +55,8 @@ interface SubmitDailyGuessRequest {
state: DailyGameState;
sig: string;
guess?: SongGuessPayload | null;
+ username?: string;
+ groupId?: string;
}
interface SubmitDailyGuessResponse {
diff --git a/src/helpers/group.ts b/src/helpers/group.ts
new file mode 100644
index 0000000..2a10c36
--- /dev/null
+++ b/src/helpers/group.ts
@@ -0,0 +1,141 @@
+const API_URL = import.meta.env.VITE_HEARDLE_API_URL ?? "http://localhost:3001";
+
+export const GROUP_MEMBERSHIP_STORAGE_KEY = "groupMembership";
+
+export interface GroupMembership {
+ groupId: string;
+ groupName: string;
+ username: string;
+ joinToken?: string;
+}
+
+export interface GroupDailyStatus {
+ groupId: string;
+ groupName: string;
+ currentStreak: number;
+ finishedUsers: string[];
+}
+
+export type GroupStatusMode = "daily" | "dailyMV";
+
+export function getStoredGroupMembership(): GroupMembership | null {
+ const raw = localStorage.getItem(GROUP_MEMBERSHIP_STORAGE_KEY);
+ if (!raw) return null;
+
+ try {
+ const parsed = JSON.parse(raw) as Partial<GroupMembership>;
+ if (
+ typeof parsed.groupId !== "string" ||
+ typeof parsed.groupName !== "string" ||
+ typeof parsed.username !== "string"
+ ) {
+ localStorage.removeItem(GROUP_MEMBERSHIP_STORAGE_KEY);
+ return null;
+ }
+
+ return {
+ groupId: parsed.groupId,
+ groupName: parsed.groupName,
+ username: parsed.username,
+ joinToken: typeof parsed.joinToken === "string" ? parsed.joinToken : undefined,
+ };
+ } catch {
+ localStorage.removeItem(GROUP_MEMBERSHIP_STORAGE_KEY);
+ return null;
+ }
+}
+
+export function saveGroupMembership(membership: GroupMembership) {
+ localStorage.setItem(GROUP_MEMBERSHIP_STORAGE_KEY, JSON.stringify(membership));
+}
+
+export async function createGroup(
+ name: string,
+ username: string
+): Promise<GroupMembership> {
+ const response = await fetch(`${API_URL}/create-group`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name, username }),
+ });
+
+ if (!response.ok) {
+ const fallback = `Failed to create group: ${response.statusText}`;
+ try {
+ const payload = (await response.json()) as { error?: unknown };
+ throw new Error(typeof payload.error === "string" ? payload.error : fallback);
+ } catch {
+ throw new Error(fallback);
+ }
+ }
+
+ const payload = (await response.json()) as {
+ group: {
+ id: string;
+ name: string;
+ joinToken: string;
+ };
+ };
+
+ return {
+ groupId: payload.group.id,
+ groupName: payload.group.name,
+ username,
+ joinToken: payload.group.joinToken,
+ };
+}
+
+export async function joinGroupByToken(
+ joinToken: string,
+ username: string
+): Promise<GroupMembership> {
+ const response = await fetch(`${API_URL}/join-group`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ joinToken, username }),
+ });
+
+ if (!response.ok) {
+ const fallback = `Failed to join group: ${response.statusText}`;
+ try {
+ const payload = (await response.json()) as { error?: unknown };
+ throw new Error(typeof payload.error === "string" ? payload.error : fallback);
+ } catch {
+ throw new Error(fallback);
+ }
+ }
+
+ const payload = (await response.json()) as {
+ groupId: string;
+ groupName: string;
+ username: string;
+ joinToken: string;
+ };
+
+ return {
+ groupId: payload.groupId,
+ groupName: payload.groupName,
+ username: payload.username,
+ joinToken: payload.joinToken,
+ };
+}
+
+export async function getGroupDailyStatus(
+ groupId: string,
+ date: string,
+ mode: GroupStatusMode = "daily"
+): Promise<GroupDailyStatus> {
+ const response = await fetch(
+ `${API_URL}/group-status?groupId=${encodeURIComponent(groupId)}&date=${encodeURIComponent(date)}&mode=${encodeURIComponent(mode)}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch group status: ${response.statusText}`);
+ }
+
+ return (await response.json()) as GroupDailyStatus;
+}
diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts
index 0cd448e..07eb242 100644
--- a/src/hooks/useGameState.ts
+++ b/src/hooks/useGameState.ts
@@ -6,6 +6,7 @@ import {
submitDailyGuess,
submitDailyMVGuess,
} from "../helpers/fetchSolution";
+import { getStoredGroupMembership } from "../helpers/group";
export type GameMode = "daily" | "dailyMV";
@@ -231,6 +232,8 @@ export function useGameState({
setIsSubmitting(true);
try {
+ const membership = getStoredGroupMembership();
+
const res = await submitGuessFn({
sessionToken,
sig: stats.sig,
@@ -241,6 +244,8 @@ export function useGameState({
guesses: guesses.filter(isAnsweredGuess),
},
guess: null,
+ groupId: membership?.groupId,
+ username: membership?.username,
});
applyServerState(res.state, res.sig);
@@ -282,6 +287,8 @@ export function useGameState({
setIsSubmitting(true);
try {
+ const membership = getStoredGroupMembership();
+
const res = await submitGuessFn({
sessionToken,
sig: stats.sig,
@@ -295,6 +302,8 @@ export function useGameState({
artist: selectedSong.artist,
name: selectedSong.name,
},
+ groupId: membership?.groupId,
+ username: membership?.username,
});
applyServerState(res.state, res.sig);
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index 85c0369..7cf2cb7 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -1,7 +1,14 @@
+import React from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { appName } from "../constants";
+import {
+ createGroup,
+ getStoredGroupMembership,
+ joinGroupByToken,
+ saveGroupMembership,
+} from "../helpers/group";
const getColor = (variant?: "pink" | "purple" | "cyan") => {
switch (variant) {
@@ -147,6 +154,84 @@ const ModeDescription = styled.span`
margin-top: 6px;
`;
+const GroupJoinCard = styled.div`
+ width: 100%;
+ max-width: 440px;
+ border: 1px solid var(--cl-gray-4);
+ border-radius: 8px;
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+`;
+
+const ExpandButton = styled.button`
+ width: 100%;
+ max-width: 440px;
+ border: 1px solid var(--cl-gray-4);
+ border-radius: 8px;
+ background: transparent;
+ color: var(--cl-white);
+ padding: 12px 14px;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.88rem;
+ font-weight: 700;
+ text-align: left;
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--cl-cyan-6);
+ }
+`;
+
+const GroupJoinTitle = styled.h3`
+ margin: 0;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: var(--cl-white);
+`;
+
+const GroupJoinRow = styled.div`
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+`;
+
+const GroupInput = styled.input`
+ flex: 1;
+ min-width: 180px;
+ background: transparent;
+ border: 1px solid var(--cl-gray-4);
+ color: var(--cl-white);
+ padding: 10px;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.8rem;
+`;
+
+const JoinButton = styled.button`
+ border: 1px solid var(--cl-cyan-6);
+ color: var(--cl-cyan-6);
+ background: transparent;
+ padding: 10px 14px;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+
+ &:hover {
+ background: var(--cl-cyan-6);
+ color: var(--cl-white, #fff);
+ }
+`;
+
+const GroupStatus = styled.p`
+ margin: 0;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.76rem;
+ color: var(--cl-gray-7);
+`;
+
const Footer = styled.div`
margin-top: 8px;
display: flex;
@@ -180,6 +265,69 @@ const GitHubIcon = styled.svg`
export function LandingPage() {
const navigate = useNavigate();
+ const initialMembership = React.useMemo(() => getStoredGroupMembership(), []);
+ const [currentMembership, setCurrentMembership] = React.useState(initialMembership);
+
+ const [groupName, setGroupName] = React.useState("");
+ const [joinToken, setJoinToken] = React.useState(
+ initialMembership?.joinToken ?? ""
+ );
+ const [username, setUsername] = React.useState(
+ initialMembership?.username ?? localStorage.getItem("groupUsername") ?? ""
+ );
+ const [joinMessage, setJoinMessage] = React.useState("");
+ const [createMessage, setCreateMessage] = React.useState("");
+ const [isJoining, setIsJoining] = React.useState(false);
+ const [isCreating, setIsCreating] = React.useState(false);
+ const [isCreateOpen, setIsCreateOpen] = React.useState(false);
+ const [isJoinOpen, setIsJoinOpen] = React.useState(false);
+
+ const createNewGroup = React.useCallback(async () => {
+ if (!groupName.trim() || !username.trim()) {
+ setCreateMessage("Enter group name and username.");
+ return;
+ }
+
+ setIsCreating(true);
+ try {
+ const membership = await createGroup(groupName, username);
+ saveGroupMembership(membership);
+ setCurrentMembership(membership);
+ localStorage.setItem("groupUsername", membership.username);
+ setJoinToken(membership.joinToken ?? "");
+ setCreateMessage(
+ `Created ${membership.groupName}. Join token: ${membership.joinToken}`
+ );
+ } catch (error) {
+ setCreateMessage(
+ error instanceof Error ? error.message : "Unable to create group."
+ );
+ } finally {
+ setIsCreating(false);
+ }
+ }, [groupName, username]);
+
+ const joinGroup = React.useCallback(async () => {
+ if (!joinToken.trim() || !username.trim()) {
+ setJoinMessage("Enter both token and username.");
+ return;
+ }
+
+ setIsJoining(true);
+ try {
+ const membership = await joinGroupByToken(joinToken, username);
+ saveGroupMembership(membership);
+ setCurrentMembership(membership);
+ localStorage.setItem("groupUsername", membership.username);
+ setJoinMessage(`Joined ${membership.groupName} as ${membership.username}`);
+ } catch (error) {
+ setJoinMessage(
+ error instanceof Error ? error.message : "Unable to join group."
+ );
+ } finally {
+ setIsJoining(false);
+ }
+ }, [joinToken, username]);
return (
<Container>
@@ -216,6 +364,65 @@ export function LandingPage() {
</div>
</ModeGroups>
+ <ExpandButton onClick={() => setIsCreateOpen((open) => !open)}>
+ {isCreateOpen ? "▾" : "▸"} Create Group
+ </ExpandButton>
+ {isCreateOpen && (
+ <GroupJoinCard>
+ <GroupJoinTitle>Create Group</GroupJoinTitle>
+ <GroupJoinRow>
+ <GroupInput
+ placeholder="Group name"
+ value={groupName}
+ onChange={(event) => setGroupName(event.target.value)}
+ maxLength={64}
+ />
+ <GroupInput
+ placeholder="Username"
+ value={username}
+ onChange={(event) => setUsername(event.target.value)}
+ maxLength={32}
+ />
+ <JoinButton onClick={createNewGroup} disabled={isCreating}>
+ {isCreating ? "Creating..." : "Create"}
+ </JoinButton>
+ </GroupJoinRow>
+ {createMessage && <GroupStatus>{createMessage}</GroupStatus>}
+ </GroupJoinCard>
+ )}
+
+ <ExpandButton onClick={() => setIsJoinOpen((open) => !open)}>
+ {isJoinOpen ? "▾" : "▸"} Join Group
+ </ExpandButton>
+ {isJoinOpen && (
+ <GroupJoinCard>
+ <GroupJoinTitle>Join a Group</GroupJoinTitle>
+ <GroupJoinRow>
+ <GroupInput
+ placeholder="Join token"
+ value={joinToken}
+ onChange={(event) => setJoinToken(event.target.value.toUpperCase())}
+ maxLength={12}
+ />
+ <GroupInput
+ placeholder="Username"
+ value={username}
+ onChange={(event) => setUsername(event.target.value)}
+ maxLength={32}
+ />
+ <JoinButton onClick={joinGroup} disabled={isJoining}>
+ {isJoining ? "Joining..." : "Join"}
+ </JoinButton>
+ </GroupJoinRow>
+ {currentMembership && (
+ <GroupStatus>
+ Current group: {currentMembership.groupName} ({currentMembership.username})
+ </GroupStatus>
+ )}
+ {joinMessage && <GroupStatus>{joinMessage}</GroupStatus>}
+ </GroupJoinCard>
+ )}
+
<Footer>
<GitHubLink
href="https://github.com/pinapelz/k-heardle"
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage