From 7431156386a02304c75683772db97595975e82ec Mon Sep 17 00:00:00 2001 From: Yash Kewlani Date: Wed, 1 Jul 2026 10:37:30 +0530 Subject: [PATCH 01/10] docs(pwa): document service-worker caching and force-refresh steps --- docs/OFFLINE_CACHING.md | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/OFFLINE_CACHING.md diff --git a/docs/OFFLINE_CACHING.md b/docs/OFFLINE_CACHING.md new file mode 100644 index 0000000..033ca4b --- /dev/null +++ b/docs/OFFLINE_CACHING.md @@ -0,0 +1,43 @@ +# Offline caching and stale content + +StudyMap is a Progressive Web App (PWA). It registers a service worker +(`public/sw.js`) so the map still opens on exam day with a weak or absent +signal. This also means the app can keep serving cached content after a +deploy ships, which can look like "the fix never happened" when really your +browser or installed PWA is still holding the previous version. + +## What gets cached + +The service worker keeps two caches, both versioned by `VERSION` in +`public/sw.js`: + +- **App cache** (`app-`): the app shell (`/`, `/offline`, + `/manifest.webmanifest`), plus build output under `/_next/static` and + `/icons`. Static build assets are cache-first since they're + content-hashed and never change under the same URL. Page navigations try + the network first and fall back to the cache when offline. +- **Tile cache** (`tiles-`): map tiles from `api.maptiler.com`, + cache-first, capped at 300 tiles so it doesn't grow unbounded. + +On every deploy, `VERSION` changes, which causes the new service worker to +delete all caches that don't match the new version on activation. In most +cases a normal reload picks up the new version automatically. Staleness +happens when the browser hasn't fetched the new `sw.js` yet, usually because +the old service worker is still controlling the page or the browser served +`sw.js` itself from an HTTP cache. + +## Forcing a fresh load + +If the map or UI looks out of date after a known deploy, try these in order: + +1. **Hard refresh**: reload while bypassing the cache (Cmd+Shift+R on + Mac, Ctrl+Shift+R on Windows/Linux, or Ctrl+F5). +2. **Clear site data**: in browser dev tools, Application (Chrome/Edge) or + Storage (Firefox) tab, clear Service Workers, Cache Storage, and Storage + for the site, then reload. +3. **Uninstall and reinstall the PWA**: if StudyMap was installed as an app, + remove it from your device and reinstall from the site to pick up a + fresh service worker. + +If none of these help, the deploy itself likely hasn't shipped yet, so +check the deployment status before assuming it's a caching issue. From 6590694f04a62ae46ea890b3df7e1a83f081a7ee Mon Sep 17 00:00:00 2001 From: Yash Kewlani Date: Wed, 1 Jul 2026 10:48:36 +0530 Subject: [PATCH 02/10] feat(calendar): add personal deadlines/events for signed-in users --- src/app/calendar/CalendarView.tsx | 198 ++++++++++++++++-- .../calendar/personal-event-dialog.tsx | 186 ++++++++++++++++ src/lib/user-events.ts | 80 +++++++ .../20260701_create_user_events.sql | 35 ++++ 4 files changed, 485 insertions(+), 14 deletions(-) create mode 100644 src/components/calendar/personal-event-dialog.tsx create mode 100644 src/lib/user-events.ts create mode 100644 supabase/migrations/20260701_create_user_events.sql diff --git a/src/app/calendar/CalendarView.tsx b/src/app/calendar/CalendarView.tsx index b693c9f..fa47975 100644 --- a/src/app/calendar/CalendarView.tsx +++ b/src/app/calendar/CalendarView.tsx @@ -1,14 +1,23 @@ "use client"; -import { useState } from "react"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { User } from "@supabase/supabase-js"; +import { ChevronLeft, ChevronRight, Pencil, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { BOARD_LABELS, EXAM_EVENTS, type ExamBoard, type ExamEvent, } from "@/lib/exam-dates"; +import { createClient } from "@/lib/supabase/client"; +import { + fetchUserEvents, + PERSONAL_EVENT_CATEGORIES, + type PersonalEvent, +} from "@/lib/user-events"; +import { PersonalEventDialog } from "@/components/calendar/personal-event-dialog"; const BOARDS: ExamBoard[] = ["SAT", "IB", "IGCSE"]; @@ -18,6 +27,12 @@ const BOARD_COLORS: Record = { IGCSE: "#10b981", }; +const PERSONAL_EVENT_COLOR = "#ec4899"; + +const PERSONAL_CATEGORY_LABELS = Object.fromEntries( + PERSONAL_EVENT_CATEGORIES.map((c) => [c.value, c.label]), +); + const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", @@ -54,6 +69,24 @@ function getEventsInMonth(year: number, month: number): ExamEvent[] { }); } +function toIsoDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function getPersonalEventsInMonth( + events: PersonalEvent[], + year: number, + month: number, +): PersonalEvent[] { + return events.filter((ev) => { + const d = new Date(ev.date + "T00:00:00"); + return d.getFullYear() === year && d.getMonth() === month; + }); +} + function getActiveDaysForEvent(ev: ExamEvent, year: number, month: number): Set { const set = new Set(); if (!/^\d{4}-\d{2}-\d{2}$/.test(ev.examStart)) return set; @@ -74,10 +107,61 @@ export function CalendarView() { const [year, setYear] = useState(today.getFullYear()); const [month, setMonth] = useState(today.getMonth()); + const [user, setUser] = useState(null); + const [personalEvents, setPersonalEvents] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingEvent, setEditingEvent] = useState(null); + + useEffect(() => { + const supabase = createClient(); + supabase.auth.getUser().then(({ data }) => setUser(data.user)); + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_, session) => setUser(session?.user ?? null)); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + if (!user) { + setPersonalEvents([]); + return; + } + fetchUserEvents() + .then(setPersonalEvents) + .catch(() => setPersonalEvents([])); + }, [user]); + + function handleSaved(saved: PersonalEvent) { + setPersonalEvents((prev) => { + const next = prev.some((e) => e.id === saved.id) + ? prev.map((e) => (e.id === saved.id ? saved : e)) + : [...prev, saved]; + return [...next].sort((a, b) => a.date.localeCompare(b.date)); + }); + } + + function handleDeleted(id: string) { + setPersonalEvents((prev) => prev.filter((e) => e.id !== id)); + } + + function openAddDialog() { + setEditingEvent(null); + setDialogOpen(true); + } + + function openEditDialog(ev: PersonalEvent) { + setEditingEvent(ev); + setDialogOpen(true); + } + const events = getEventsInMonth(year, month); + const personalEventsThisMonth = getPersonalEventsInMonth(personalEvents, year, month); const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const isThisMonth = today.getFullYear() === year && today.getMonth() === month; + const defaultDialogDate = isThisMonth + ? toIsoDate(today) + : toIsoDate(new Date(year, month, 1)); const dayColors: Record = {}; for (const ev of events) { @@ -89,6 +173,11 @@ export function CalendarView() { }); } + const personalDays = new Set(); + for (const ev of personalEventsThisMonth) { + personalDays.add(new Date(ev.date + "T00:00:00").getDate()); + } + const cells: (number | null)[] = [ ...Array(firstDay).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1), @@ -129,7 +218,7 @@ export function CalendarView() { {/* Board legend */} -
+
{BOARDS.map((board) => (
))} + {user && ( +
+ + Your events +
+ )}
+ {/* Personal events for this month */} + {user && ( +
+
+

+ Your events +

+ +
+ + {personalEventsThisMonth.length === 0 ? ( +

+ No personal events this month. +

+ ) : ( + personalEventsThisMonth.map((ev) => ( +
+
+ +

{ev.title}

+ + {PERSONAL_CATEGORY_LABELS[ev.category] ?? ev.category} + + +
+

{formatDate(ev.date)}

+ {ev.notes && ( +

{ev.notes}

+ )} +
+ )) + )} +
+ )} + {/* Events list for this month */}
{events.length === 0 ? ( @@ -234,26 +381,32 @@ export function CalendarView() { const colors = dayColors[day] ?? []; const hasEvent = colors.length > 0; + const hasPersonalEvent = personalDays.has(day); const isToday = isThisMonth && today.getDate() === day; + const titleParts = [ + ...(hasEvent + ? events + .filter((ev) => getActiveDaysForEvent(ev, year, month).has(day)) + .map((ev) => ev.session) + : []), + ...(hasPersonalEvent + ? personalEventsThisMonth + .filter((ev) => new Date(ev.date + "T00:00:00").getDate() === day) + .map((ev) => ev.title) + : []), + ]; return (
getActiveDaysForEvent(ev, year, month).has(day)) - .map((ev) => ev.session) - .join(", ") - : undefined - } + title={titleParts.length ? titleParts.join(", ") : undefined} > {day} @@ -274,11 +427,28 @@ export function CalendarView() { ))}
)} + {hasPersonalEvent && ( + + )}
); })}
+ + {user && ( + + )} ); } diff --git a/src/components/calendar/personal-event-dialog.tsx b/src/components/calendar/personal-event-dialog.tsx new file mode 100644 index 0000000..353e3f9 --- /dev/null +++ b/src/components/calendar/personal-event-dialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + createUserEvent, + deleteUserEvent, + updateUserEvent, + PERSONAL_EVENT_CATEGORIES, + type PersonalEvent, + type PersonalEventCategory, +} from "@/lib/user-events"; + +interface PersonalEventDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + event: PersonalEvent | null; + defaultDate: string; + onSaved: (event: PersonalEvent) => void; + onDeleted: (id: string) => void; +} + +export function PersonalEventDialog({ + open, + onOpenChange, + event, + defaultDate, + onSaved, + onDeleted, +}: PersonalEventDialogProps) { + const [title, setTitle] = React.useState(""); + const [date, setDate] = React.useState(defaultDate); + const [category, setCategory] = React.useState("deadline"); + const [notes, setNotes] = React.useState(""); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (!open) return; + setTitle(event?.title ?? ""); + setDate(event?.date ?? defaultDate); + setCategory(event?.category ?? "deadline"); + setNotes(event?.notes ?? ""); + setError(null); + }, [open, event, defaultDate]); + + async function handleSave() { + if (!title.trim() || !date) return; + setSaving(true); + setError(null); + try { + const input = { + title: title.trim(), + date, + category, + notes: notes.trim() || null, + }; + const saved = event + ? await updateUserEvent(event.id, input) + : await createUserEvent(input); + onSaved(saved); + onOpenChange(false); + } catch { + setError("Couldn't save this event. Try again."); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!event) return; + setSaving(true); + setError(null); + try { + await deleteUserEvent(event.id); + onDeleted(event.id); + onOpenChange(false); + } catch { + setError("Couldn't delete this event. Try again."); + } finally { + setSaving(false); + } + } + + return ( + + + + {event ? "Edit event" : "Add event"} + + +
+
+ + setTitle(e.target.value)} + placeholder="Application deadline" + maxLength={120} + /> +
+ +
+ + setDate(e.target.value)} + /> +
+ +
+ + +
+ +
+ +