Mega push vol 5, working on messaging now

This commit is contained in:
AlacrisDevs
2026-02-07 01:31:55 +02:00
parent d8bbfd9dc3
commit e55881b38b
77 changed files with 8478 additions and 1554 deletions

View File

@@ -1,6 +1,14 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { Button, Modal, Avatar } from "$lib/components/ui";
import { getContext, onMount, onDestroy } from "svelte";
import { createLogger } from "$lib/utils/logger";
import {
Button,
Modal,
Avatar,
ContextMenu,
Input,
Textarea,
} from "$lib/components/ui";
import { Calendar } from "$lib/components/calendar";
import {
getCalendarSubscribeUrl,
@@ -9,6 +17,7 @@
import type { CalendarEvent } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
@@ -22,6 +31,7 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.calendar");
let events = $state(data.events);
$effect(() => {
@@ -32,17 +42,86 @@
let isLoadingGoogle = $state(false);
let orgCalendarId = $state<string | null>(null);
let orgCalendarName = $state<string | null>(null);
// Track Google event IDs that are pending deletion to prevent ghost re-appearance
let deletedGoogleEventIds = $state<Set<string>>(new Set());
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const allEvents = $derived([...events, ...googleEvents]);
// Deduplicate: exclude Google Calendar events that already exist locally (synced events)
// Also exclude events that are pending deletion
const allEvents = $derived.by(() => {
const localGoogleIds = new Set(
events
.filter((e) => e.google_event_id)
.map((e) => e.google_event_id),
);
const filteredGoogle = googleEvents.filter((ge) => {
const rawId = ge.id.replace("google-", "");
if (localGoogleIds.has(rawId)) return false;
if (deletedGoogleEventIds.has(rawId)) return false;
return true;
});
return [...events, ...filteredGoogle];
});
let showEventModal = $state(false);
let showEventFormModal = $state(false);
let eventFormMode = $state<"create" | "edit">("create");
let isDeleting = $state(false);
let isSavingEvent = $state(false);
let selectedEvent = $state<CalendarEvent | null>(null);
function handleDateClick(_date: Date) {
// Event creation disabled
// Event form state
let eventTitle = $state("");
let eventDescription = $state("");
let eventDate = $state("");
let eventStartTime = $state("09:00");
let eventEndTime = $state("10:00");
let eventAllDay = $state(false);
let eventColor = $state("#7986cb");
let syncToGoogleCal = $state(true);
// Google Calendar official event colors (colorId → hex)
const GCAL_COLORS: Record<string, string> = {
"1": "#7986cb", // Lavender
"2": "#33b679", // Sage
"3": "#8e24aa", // Grape
"4": "#e67c73", // Flamingo
"5": "#f6bf26", // Banana
"6": "#f4511e", // Tangerine
"7": "#039be5", // Peacock
"8": "#616161", // Graphite
"9": "#3f51b5", // Blueberry
"10": "#0b8043", // Basil
"11": "#d50000", // Tomato
};
// Reverse map: hex → colorId (for pushing to Google)
const HEX_TO_COLOR_ID: Record<string, string> = Object.fromEntries(
Object.entries(GCAL_COLORS).map(([id, hex]) => [hex, id]),
);
const EVENT_COLORS = Object.values(GCAL_COLORS);
function toLocalDateString(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 handleDateClick(date: Date) {
eventFormMode = "create";
eventTitle = "";
eventDescription = "";
eventDate = toLocalDateString(date);
eventStartTime = "09:00";
eventEndTime = "10:00";
eventAllDay = false;
eventColor = "#7986cb";
syncToGoogleCal = isOrgCalendarConnected;
showEventFormModal = true;
}
function handleEventClick(event: CalendarEvent) {
@@ -50,11 +129,207 @@
showEventModal = true;
}
function openEditEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
eventFormMode = "edit";
eventTitle = selectedEvent.title;
eventDescription = selectedEvent.description ?? "";
const start = new Date(selectedEvent.start_time);
const end = new Date(selectedEvent.end_time);
eventDate = toLocalDateString(start);
eventStartTime = start.toTimeString().slice(0, 5);
eventEndTime = end.toTimeString().slice(0, 5);
eventAllDay = selectedEvent.all_day ?? false;
eventColor = selectedEvent.color ?? "#7986cb";
showEventModal = false;
showEventFormModal = true;
}
/**
* Push event to Google Calendar in the background.
* Does not block the UI — updates google_event_id on success.
*/
async function syncToGoogle(
action: "create" | "update" | "delete",
eventData: {
id?: string;
google_event_id?: string | null;
title?: string;
description?: string | null;
start_time?: string;
end_time?: string;
all_day?: boolean;
color?: string;
},
) {
const colorId = eventData.color
? (HEX_TO_COLOR_ID[eventData.color] ?? undefined)
: undefined;
try {
if (action === "create") {
const res = await fetch("/api/google-calendar/push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
if (res.ok) {
const { google_event_id } = await res.json();
if (google_event_id && eventData.id) {
// Store the Google event ID back to Supabase
await supabase
.from("calendar_events")
.update({
google_event_id,
synced_at: new Date().toISOString(),
})
.eq("id", eventData.id);
// Update local state
events = events.map((e) =>
e.id === eventData.id
? {
...e,
google_event_id,
synced_at: new Date().toISOString(),
}
: e,
);
}
}
} else if (action === "update" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
} else if (action === "delete" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
}),
});
}
} catch (e) {
log.error("Google Calendar sync failed", {
error: e,
data: { action },
});
}
}
async function handleSaveEvent() {
if (!eventTitle.trim() || !eventDate) return;
isSavingEvent = true;
const startTime = eventAllDay
? `${eventDate}T00:00:00`
: `${eventDate}T${eventStartTime}:00`;
const endTime = eventAllDay
? `${eventDate}T23:59:59`
: `${eventDate}T${eventEndTime}:00`;
if (eventFormMode === "edit" && selectedEvent) {
const { error } = await supabase
.from("calendar_events")
.update({
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
})
.eq("id", selectedEvent.id);
if (!error) {
events = events.map((e) =>
e.id === selectedEvent!.id
? {
...e,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
}
: e,
);
// Push update to Google Calendar in background
syncToGoogle("update", {
google_event_id: selectedEvent.google_event_id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
} else {
const { data: newEvent, error } = await supabase
.from("calendar_events")
.insert({
org_id: data.org.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
created_by: data.user?.id,
})
.select()
.single();
if (!error && newEvent) {
events = [...events, newEvent as CalendarEvent];
// Push new event to Google Calendar if sync is enabled
if (syncToGoogleCal && isOrgCalendarConnected) {
syncToGoogle("create", {
id: newEvent.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
}
}
showEventFormModal = false;
selectedEvent = null;
isSavingEvent = false;
}
async function handleDeleteEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
isDeleting = true;
try {
const googleEventId = selectedEvent.google_event_id;
const { error } = await supabase
.from("calendar_events")
.delete()
@@ -62,11 +337,25 @@
if (!error) {
events = events.filter((e) => e.id !== selectedEvent?.id);
if (googleEventId) {
// Immediately exclude from Google events display
deletedGoogleEventIds = new Set([
...deletedGoogleEventIds,
googleEventId,
]);
googleEvents = googleEvents.filter(
(ge) => ge.id !== `google-${googleEventId}`,
);
// Await Google delete so it completes before any refresh
await syncToGoogle("delete", {
google_event_id: googleEventId,
});
}
showEventModal = false;
selectedEvent = null;
}
} catch (e) {
console.error("Failed to delete event:", e);
log.error("Failed to delete event", { error: e });
}
isDeleting = false;
}
@@ -78,8 +367,33 @@
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
let pollInterval: ReturnType<typeof setInterval> | null = null;
function handleWindowFocus() {
if (isOrgCalendarConnected) {
loadGoogleCalendarEvents();
}
}
onMount(async () => {
await loadGoogleCalendarEvents();
// Re-fetch when user returns to the tab (e.g. after editing in Google Calendar)
window.addEventListener("focus", handleWindowFocus);
// Poll every 60s for changes made in Google Calendar
pollInterval = setInterval(() => {
if (isOrgCalendarConnected && !document.hidden) {
loadGoogleCalendarEvents();
}
}, 60_000);
});
onDestroy(() => {
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleWindowFocus);
}
if (pollInterval) clearInterval(pollInterval);
});
async function loadGoogleCalendarEvents() {
@@ -96,31 +410,37 @@
orgCalendarId = result.calendar_id;
orgCalendarName = result.calendar_name;
if (result.events && result.events.length > 0) {
googleEvents = result.events.map(
(ge: GoogleCalendarEvent) => ({
id: `google-${ge.id}`,
org_id: data.org.id,
title: ge.summary || "(No title)",
description: ge.description ?? null,
start_time:
ge.start.dateTime ||
`${ge.start.date}T00:00:00`,
end_time:
ge.end.dateTime || `${ge.end.date}T23:59:59`,
all_day: !ge.start.dateTime,
color: "#4285f4",
recurrence: null,
created_by: data.user?.id ?? "",
created_at: new Date().toISOString(),
}),
) as CalendarEvent[];
}
const fetchedEvents = result.events ?? [];
googleEvents = fetchedEvents.map((ge: GoogleCalendarEvent) => ({
id: `google-${ge.id}`,
org_id: data.org.id,
title: ge.summary || "(No title)",
description: ge.description ?? null,
start_time:
ge.start.dateTime || `${ge.start.date}T00:00:00`,
end_time: ge.end.dateTime || `${ge.end.date}T23:59:59`,
all_day: !ge.start.dateTime,
color: ge.colorId
? (GCAL_COLORS[ge.colorId] ?? "#7986cb")
: "#7986cb",
recurrence: null,
created_by: data.user?.id ?? "",
created_at: new Date().toISOString(),
})) as CalendarEvent[];
// Clear deleted IDs that Google has confirmed are gone
const fetchedIds = new Set(
fetchedEvents.map((ge: GoogleCalendarEvent) => ge.id),
);
deletedGoogleEventIds = new Set(
[...deletedGoogleEventIds].filter((id) =>
fetchedIds.has(id),
),
);
} else if (result.error) {
console.error("Calendar API error:", result.error);
log.error("Calendar API error", { error: result.error });
}
} catch (e) {
console.error("Failed to load Google events:", e);
log.error("Failed to load Google events", { error: e });
}
isLoadingGoogle = false;
}
@@ -139,36 +459,49 @@
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Calendar" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Calendar</h1>
{#if isOrgCalendarConnected}
<button
type="button"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-[32px] hover:bg-primary/20 transition-colors"
onclick={subscribeToCalendar}
title="Add to your Google Calendar"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
add
</span>
Subscribe
</button>
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
aria-label="More options"
<h1 class="flex-1 font-heading text-h1 text-white">
{m.calendar_title()}
</h1>
<Button size="md" onclick={() => handleDateClick(new Date())}
>{m.btn_new()}</Button
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
<ContextMenu
items={[
...(isOrgCalendarConnected
? [
{
label: m.calendar_subscribe(),
icon: "add",
onclick: subscribeToCalendar,
},
]
: []),
{
label: m.calendar_refresh(),
icon: "refresh",
onclick: () => {
loadGoogleCalendarEvents();
},
},
...(isAdmin
? [
{
label: "",
icon: "",
onclick: () => {},
divider: true,
},
{
label: m.calendar_settings(),
icon: "settings",
onclick: () => {
window.location.href = `/${data.org.slug}/settings?tab=integrations`;
},
},
]
: []),
]}
/>
</header>
<!-- Calendar Grid -->
@@ -270,18 +603,22 @@
<span class="text-light/60 text-sm">
{selectedEvent.id.startsWith("google-")
? "Google Calendar Event"
: "Local Event"}
: selectedEvent.google_event_id
? "Synced to Google Calendar"
: "Local Event"}
</span>
</div>
<!-- Google Calendar link -->
{#if selectedEvent.id.startsWith("google-") && orgCalendarId}
{#if (selectedEvent.id.startsWith("google-") ? selectedEvent.id.replace("google-", "") : selectedEvent.google_event_id) && orgCalendarId}
{@const googleId = selectedEvent.id.startsWith("google-")
? selectedEvent.id.replace("google-", "")
: selectedEvent.google_event_id}
<div class="pt-3 border-t border-light/10">
<a
href="https://calendar.google.com/calendar/u/0/r/eventedit/{selectedEvent.id.replace(
'google-',
'',
)}?cid={encodeURIComponent(orgCalendarId)}"
href="https://calendar.google.com/calendar/u/0/r/eventedit/{googleId}?cid={encodeURIComponent(
orgCalendarId,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors"
@@ -306,15 +643,14 @@
</svg>
Open in Google Calendar
</a>
<p class="text-xs text-light/40 mt-2">
Edit this event directly in Google Calendar
</p>
</div>
{/if}
<!-- Delete local event -->
<!-- Edit/Delete local event -->
{#if !selectedEvent.id.startsWith("google-")}
<div class="pt-3 border-t border-light/10">
<div
class="pt-3 border-t border-light/10 flex items-center justify-between"
>
<Button
variant="danger"
onclick={handleDeleteEvent}
@@ -322,8 +658,137 @@
>
Delete Event
</Button>
<Button onclick={openEditEvent}>Edit Event</Button>
</div>
{/if}
</div>
{/if}
</Modal>
<!-- Event Create/Edit Form Modal -->
<Modal
isOpen={showEventFormModal}
onClose={() => (showEventFormModal = false)}
title={eventFormMode === "edit"
? m.calendar_edit_event()
: m.calendar_create_event()}
>
<div class="space-y-4">
<Input
label={m.calendar_event_title()}
bind:value={eventTitle}
placeholder={m.calendar_event_title_placeholder()}
/>
<Input
type="date"
label={m.calendar_event_date()}
bind:value={eventDate}
/>
<label
class="flex items-center gap-2 text-sm text-light cursor-pointer"
>
<input type="checkbox" bind:checked={eventAllDay} class="rounded" />
All day event
</label>
{#if !eventAllDay}
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>Start</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventStartTime}
/>
</div>
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>End</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventEndTime}
/>
</div>
</div>
{/if}
<Textarea
label={m.calendar_event_desc()}
bind:value={eventDescription}
placeholder="Add a description..."
rows={3}
/>
<div>
<span class="block text-sm font-medium text-light mb-2">Color</span>
<div class="flex gap-2">
{#each EVENT_COLORS as color}
<button
type="button"
class="w-7 h-7 rounded-full transition-transform {eventColor ===
color
? 'ring-2 ring-white scale-110'
: ''}"
style="background-color: {color}"
onclick={() => (eventColor = color)}
></button>
{/each}
</div>
</div>
{#if isOrgCalendarConnected && eventFormMode === "create"}
<label
class="flex items-center gap-3 text-sm text-light cursor-pointer p-3 rounded-lg bg-light/5 border border-light/10"
>
<input
type="checkbox"
bind:checked={syncToGoogleCal}
class="rounded"
/>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-400" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
<span>Sync to Google Calendar</span>
</div>
</label>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showEventFormModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleSaveEvent}
loading={isSavingEvent}
disabled={!eventTitle.trim() || !eventDate}
>{eventFormMode === "edit"
? m.btn_save()
: m.btn_create()}</Button
>
</div>
</div>
</Modal>