Skip to content

Social sync: add ghost overlay sharing with realtime updates - #1273

Open
Alinapanyue wants to merge 2 commits into
jhuopensource:developfrom
Alinapanyue:feature/alinapan-social-sync-mvp
Open

Social sync: add ghost overlay sharing with realtime updates#1273
Alinapanyue wants to merge 2 commits into
jhuopensource:developfrom
Alinapanyue:feature/alinapan-social-sync-mvp

Conversation

@Alinapanyue

@Alinapanyue Alinapanyue commented Apr 13, 2026

Copy link
Copy Markdown

Why this change

The original ghost-share flow only refreshed after manual reload (or polling fallback), so shared timetable viewers could miss changes while the owner edited their schedule. This update moves the feature to true push-based updates so ghost overlays behave more like collaborative documents: once a viewer opens a share link, updates appear automatically.

To support real-time updates end-to-end, we also needed to run Django in an ASGI-capable server path instead of WSGI-only dev flow.

How we implemented it

  • switched the web runtime to Uvicorn + ASGI so HTTP and WebSocket traffic are both handled by Django in one app lifecycle
  • added Django Channels + Redis channel layer for fan-out messaging from timetable updates to all viewers subscribed to a shared link room
  • updated reverse proxy rules in Caddy to explicitly upgrade and forward /ws/... connections, which is required for browser WebSocket handshakes
  • wrapped ASGI app with ASGIStaticFilesHandler so static assets continue to load correctly in local ASGI development

Major backend changes

  • extended SharedTimetable with social-sync metadata (share_token, source_timetable, permission/expiry/revoke fields, updated_at) and migration
  • introduced share-link utilities to support new token slugs while remaining backward-compatible with existing hashid links
  • added a ghost-read API endpoint (/timetables/links/<slug>/ghost/) with access validation and school scoping
  • implemented realtime sync module + Channels consumer:
    • on PersonalTimetable save, refresh linked shared snapshots
    • publish timetable.updated events to the corresponding share room after transaction commit

Major frontend changes

  • added ghost overlay Redux slice + async actions for initial ghost fetch and WebSocket lifecycle
  • added ghost WebSocket endpoint handling and state updates on timetable.updated
  • updated calendar rendering to support conflict-aware ghost blocks (side-by-side for overlaps)
  • updated ghost slot UI to show richer details aligned with normal slots: course name + section + time + location

Infra/config changes

  • docker-compose: add Redis service and run web with Uvicorn ASGI app
  • semesterly/settings.py: configure Channels + channel layers + feature flag for ghost mode rollout
  • build/caddy/Caddyfile: add explicit websocket proxy route for /ws

Test plan

  • docker compose up --build and verify app loads under ASGI with static assets
  • enable ghost mode using a share link and verify ghost classes render with conflict layout/details
  • update source timetable in another tab/account and verify viewer ghost overlay updates without page refresh
  • run docker compose exec web python manage.py test timetable.tests
  • run frontend tests including ghost_timetable_slice.test.ts

Introduce tokenized shared timetable links, a ghost overlay API, and WebSocket-driven live updates so viewers can see owner schedule changes in real time while preserving existing share flow compatibility.

Made-with: Cursor
@Alinapanyue

Copy link
Copy Markdown
Author
Screenshot 2026-04-13 at 6 08 39 PM Screenshot 2026-04-13 at 6 08 49 PM

@Alinapanyue

Copy link
Copy Markdown
Author

Reviewer test guide (local)

If you want to validate the realtime ghost-share flow end-to-end, here is a quick checklist:

  1. Start services

    • docker compose up --build
    • Confirm app is reachable at https://jhu.sem.ly.
  2. Create / open a share link

    • In browser A (owner account), open a timetable and create/copy a share link.
    • In browser B (viewer account or incognito), open that share link and enable ghost mode.
  3. Verify initial ghost render

    • Ghost classes should appear in the calendar.
    • Overlapping ghost classes should render side-by-side (conflict layout).
    • Each ghost block should show course name + section + time + location.
  4. Verify realtime updates (no manual refresh)

    • In browser A, modify the source timetable (add/remove/swap a class) and save.
    • In browser B, confirm ghost overlay updates automatically within a second or two.
    • No page reload should be needed.
  5. Regression checks

    • Static assets/icons still load correctly.
    • Existing non-ghost timetable interactions continue to work.
  6. Optional backend/frontend tests

    • docker compose exec web python manage.py test timetable.tests
    • Run frontend tests including ghost_timetable_slice.test.ts.

Happy to help reproduce any issues if a step fails.

try {
const payload = JSON.parse(event.data);
if (payload.type === "timetable.updated") {
dispatch(fetchGhostTimetableBySlug(slug));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from my understanding, the websocket will send an event, then fetchGhostTimetableBySlug will trigger another HTTP request to fetch the new changes -- is it possible to just convey this within the WS event itself?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — addressed in a97a41647. WebSocket timetable.updated now includes sharedTimetable + courses + permission + updatedAt, and frontend applies that payload directly instead of always issuing a follow-up HTTP fetch. I kept a backward-compatible HTTP fallback path in case older payload shapes are encountered.

Comment thread analytics/models.py Outdated
def ensure_share_token(self):
if self.share_token:
return self.share_token
token = secrets.token_urlsafe(24)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re. the token generation logic, is there a potential race condition here? i feel that two users generating the same token_urlsafe at the same time is extremely unlikely, but what happens in the unlikely case that they do?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch — addressed in a97a41647. I updated ensure_share_token to retry on IntegrityError from the unique constraint, so even in the extremely unlikely collision case we regenerate and retry instead of failing.

Comment thread static/js/redux/ui/Calendar.tsx Outdated
return;
}
const raw = window.prompt(
"Paste a shared timetable link or slug to overlay as ghost."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to us developers this makes sense, but to a non-technical user this could be confusing.

possible alternative:

Paste a shared timetable link or code.

Example link: jhu.sem.ly/timetables/links/abc123
Example code: abc123

@Alinapanyue Alinapanyue Apr 22, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — updated prompt copy in a97a41647 to be more user-friendly with concrete examples: Paste a shared timetable link or code.
Example link: jhu.sem.ly/timetables/links/abc123
Example code: abc123

enabled: boolean;
shareSlug: string | null;
timetable: Timetable | null;
permission: "view" | "edit";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont see this 'edit' permission enforced anywhere, is this for later?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is currently for later phases. MVP in this PR is intentionally read-only ghost overlay (view), while edit is scaffolded metadata for follow-up collaborative editing permissions so we do not need another schema change later.

Comment thread analytics/models.py
default="view",
choices=(("view", "View"), ("edit", "Edit")),
)
expires_at = models.DateTimeField(null=True, blank=True, default=None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see conditional logic for checking share validity (is_share_access_valid) but can't find where expires_at and revoked_at get changed, is that implemented?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — expiry/revoke mutation flows are not included in this MVP yet. This PR adds the enforcement checks + fields so links can be invalidated/expired once we add management UI/API in a follow-up. Current default behavior is non-revoked + no expiry unless set manually.

Send full ghost timetable payloads over websocket events to remove follow-up HTTP fetches, add collision-safe share token retries, and clarify ghost link prompt copy for non-technical users.

Made-with: Cursor
@spencerckhuang

Copy link
Copy Markdown

@Alinapanyue re. the linter changes, see these docs: https://semesterly-v2.readthedocs.io/en/latest/contributing.html?highlight=npx%20prettier

commands to resolve the above workflows should be at the very bottom, try giving those a run and re-commit :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants