Skip to content
Open
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 @@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
.idea

# debug
npm-debug.log*
Expand Down
121 changes: 47 additions & 74 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,91 +1,64 @@
# React Developer Assignment: Cross-Tab Collaboration Dashboard
# CrossTab Collaboration Dashboard

## Time Limit: 3 hours
Simple Next.js demo that syncs presence, a shared counter, chat (typing + delete‑own + expiration), and theme across browser tabs using `react-broadcast-sync`.

You are expected to focus on the core requirements. Bonus features are optional and may be partially implemented if time allows.

## Overview

Build a real-time collaboration dashboard that synchronizes user activity across multiple browser tabs using the
<a href="https://www.npmjs.com/package/react-broadcast-sync" target="_blank" rel="noopener noreferrer">react-broadcast-sync</a> library.
Open this page in multiple tabs to try it.

---

## Setup Instructions
## Quick start

1. Create a new React project using any setup you prefer (e.g., Vite, CRA, Next.js)
2. Install the required package:
```bash
npm install react-broadcast-sync
```
3. Use any styling approach (CSS, Tailwind, MUI, etc.)
1) Install and run
```bash
pnpm install
pnpm dev
# then open http://localhost:3000
```

2) Optional: install the library directly (already listed in dependencies)
```bash
pnpm add react-broadcast-sync
```

---

## Requirements - Mandatory

### 1. Custom Hook
Create a custom hook called `useCollaborativeSession` that:
- Sets up the broadcast channel
- Manages internal state for users, chat, counter
- Exposes state and actions:
- `users`, `messages`, `counter`
- `sendMessage()`, `updateCounter()`, `markTyping()`, etc.
- Internally uses `react-broadcast-sync` to handle cross-tab communication

### 2. User Presence System
- Detect and display active users (based on tabs)
- Show a user list with:
- Username or ID (you can generate random names if needed)
- Last activity timestamp
- Detect and visually indicate when a user joins or leaves

### 3. Shared Counter
- Counter that stays synchronized across all tabs
- Any user can increment/decrement the value
- Show which user performed the last action
- Display timestamp of last action

### 4. Real-time Chat
- Text area for message writing
- Show typing indicators when users are actively typing
- Synchronize conversation content across all tabs
- For each message display which user sent it and its timestamp
- Allow users to delete **their own** messages from the chat with syncing across tabs
- Allow users to send a messages with expiration

### 5. Technical Standards
- Use proper error handling and cleanup
- Use TypeScript or well-typed PropTypes
- Abstract logic into reusable components/hooks
- Keep the code modular, readable, and clean
- Synchronize existing state on page load (rehydrate from current messages/users)
## What’s inside

---
- Custom hook `useCollaborativeSession`
- Uses `useBroadcastChannel('collab-dashboard')` from `react-broadcast-sync`
- Presence: per‑tab join/leave + lastActive
- Chat: send, delete own, timestamps, optional expiration, typing indicators (debounced)
- Counter: synced value + last actor and time
- Rehydration: new tab requests snapshot, existing tabs respond; merge is deterministic
- Theme sync (bonus): toggled theme broadcasts to all tabs

## Bonus Features
- Theme sync across tabs (light/dark mode)
- Include debouncing for frequent updates
- Implement loading states
- Add responsive layout
- Activity feed showing recent actions
- User avatar system
- Focus/cursor position indicators
- Components
- `PresenceList` – active users with last seen
- `SharedCounter` – inc/dec + last action
- `ChatPanel` – messages, typing, delete‑own, expiration input

---

## Deliverables
1. Complete source code
2. README with setup instructions and implementation notes
3. Working demo (open multiple tabs to test)
## How to test

1. Open two tabs at `http://localhost:3000`.
2. Type in one tab – the other shows a typing indicator.
3. Send a message, try deleting your own; add an expiration (ms) and watch it disappear.
4. Increment/decrement the counter – value and last actor/time stay in sync.
5. Toggle the theme – other tabs follow instantly.

---

## Evaluation Criteria
- Proper use and integration of `react-broadcast-sync`
- Correct and clean custom hook abstraction
- Working real-time sync across tabs
- Well-structured and maintainable code
- Functional and user-friendly UI
- Handling of edge cases (e.g., expired messages, tab close)
- Bonus points for creative features, polish, or great UX
## Notes

- User identity (`userId`) now persists per browser profile using `localStorage` (`collab_user_id`); opening new tabs shares the same user while each tab still has a unique `tabId` for presence granularity.
- The hook clears processed messages via `clearReceivedMessages` to avoid reprocessing.
- Expired messages are pruned every 2s.
- Presence pings every 5s to refresh `lastActive`.
- Identity (username) is deterministic from the stable `userId` via a hash (see `randomUsername`).
- Stale presence cleanup: tabs inactive >30s are pruned (unless it's your own user).
- Deterministic avatar colors derived from `userId` (see `avatarColorFromUserId`).
- Focus updates throttled (120ms) to reduce broadcast spam.
- Feed now includes expiration events (`chat:expire`).

---
11 changes: 7 additions & 4 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { CollaborativeSessionProvider } from "@/lib/context/CollaborativeSessionContext";
import "../globals.css";

const geistSans = Geist({
variable: "--font-geist-sans",
Expand All @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Collaboration Dashboard",
description: "Simple cross‑tab presence, chat, and counter demo",
};

export default function RootLayout({
Expand All @@ -25,7 +26,9 @@ export default function RootLayout({
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<CollaborativeSessionProvider>
{children}
</CollaborativeSessionProvider>
</body>
</html>
);
Expand Down
183 changes: 93 additions & 90 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,100 @@
import Image from "next/image";
import styles from "./page.module.css";
"use client";
import React from "react";
import { useCollaborativeSessionContext } from "@/lib/context/CollaborativeSessionContext";
import PresenceList from "@/components/PresenceList";
import SharedCounter from "@/components/SharedCounter";
import ChatPanel from "@/components/ChatPanel";
import { Button } from "@/components/ui/button";
import { Sun, Moon } from "lucide-react";
import ActivityFeed from "@/components/ActivityFeed";

export default function Home() {
return (
<div className={styles.page}>
<h1>Home</h1>

<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>app/page.tsx</code>.
</li>
<li>Save and see your changes instantly.</li>
</ol>
const {
loading,
users,
messages,
counter,
typingUsers,
theme,
currentUser,
sendMessage,
deleteMessage,
updateCounter,
markTyping,
toggleTheme,
feed,
focus,
updateFocus,
} = useCollaborativeSessionContext();

<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/30">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<header className="mb-8 flex items-center justify-between gap-4 rounded-2xl border border-border/50 bg-card/80 p-6 shadow-xl shadow-black/5 backdrop-blur-sm">
<div>
<h1 className="text-3xl font-bold tracking-tight">Collaboration Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">Real-time collaboration across browser tabs</p>
</div>
<Button
onClick={toggleTheme}
type="button"
aria-label="Switch theme"
aria-pressed={theme === "dark"}
title="Switch theme"
variant="outline"
size="default"
className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/50 px-3 py-2 text-sm font-medium hover:bg-muted/70 transition-colors"
>
Read our docs
</a>
</div>
</main>
<footer className={styles.footer}>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
{theme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="hidden sm:inline">{theme === "light" ? "Light" : "Dark"}</span>
<span className="sr-only">Toggle theme</span>
</Button>
</header>

{loading ? (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p className="text-lg text-muted-foreground">Loading session…</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="flex flex-col gap-6 lg:col-span-1">
<PresenceList users={users} currentUserId={currentUser.userId} />
<SharedCounter counter={counter} onChangeAction={updateCounter} />
</div>
<div className="lg:col-span-2">
<ChatPanel
messages={messages}
currentUserId={currentUser.userId}
typingUsers={typingUsers}
onSendAction={sendMessage}
onDeleteAction={deleteMessage}
onTypingAction={markTyping}
onFocusUpdateAction={updateFocus}
/>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<ActivityFeed items={feed} />
<div className="rounded-xl border border-border/50 p-4">
<h3 className="mb-2 text-sm font-semibold">Focus Indicators</h3>
<ul className="space-y-2 text-xs">
{Object.entries(focus).filter(([uid]) => uid !== currentUser.userId).map(([uid, f]) => (
<li key={uid} className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
<span>{users.find(u => u.userId === uid)?.username ?? uid} focusing {f.element}{typeof f.cursorPos === 'number' ? ` @${f.cursorPos}` : ''}</span>
</li>
))}
{Object.entries(focus).filter(([uid]) => uid !== currentUser.userId).length === 0 && (
<li className="text-muted-foreground">No other user focus</li>
)}
</ul>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
Loading