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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# testing
/coverage

*.cer

# next.js
/.next/
/out/
Expand Down Expand Up @@ -79,4 +81,7 @@ public/workbox-*.js
public/workbox-*.js.map

# Local scripts
.LOCAL
.LOCAL

.npmrc
*.cer
8 changes: 8 additions & 0 deletions app/api/external-syncs/[id]/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,13 @@ export async function syncExternalCalendar(
string,
typeof shifts.$inferSelect
>();

for (const shift of existingShifts) {
const fingerprint = createEventFingerprint(
shift.date,
shift.startTime,
shift.endTime,
shift.number,
shift.title,
undefined,
// Only use externalEventId for iCloud/Google (stable UIDs)
Expand Down Expand Up @@ -175,6 +177,7 @@ export async function syncExternalCalendar(
: baseEventId;

const title = event.summary || "Untitled Event";
const number = event.organizer || "";

// Create fingerprint based on event content
// For iCloud/Google: include eventId for stable UID-based matching
Expand All @@ -184,6 +187,7 @@ export async function syncExternalCalendar(
dayEntry.startTime,
dayEntry.endTime,
title,
number,
undefined,
externalSync.syncType !== "custom" ? eventId : undefined
);
Expand All @@ -196,6 +200,7 @@ export async function syncExternalCalendar(
startTime: dayEntry.startTime,
endTime: dayEntry.endTime,
title,
number: "",
color: externalSync.color,
notes: event.description || null,
isAllDay,
Expand Down Expand Up @@ -255,6 +260,7 @@ export async function syncExternalCalendar(
todoData.date,
todoData.startTime,
todoData.endTime,
todoData.number,
title,
undefined,
externalSync.syncType !== "custom" ? eventId : undefined
Expand All @@ -268,6 +274,7 @@ export async function syncExternalCalendar(
startTime: todoData.startTime,
endTime: todoData.endTime,
title,
number: todoData.number,
color: externalSync.color,
notes: todoData.notes,
isAllDay: todoData.isAllDay,
Expand Down Expand Up @@ -310,6 +317,7 @@ export async function syncExternalCalendar(
shift.date,
shift.startTime,
shift.endTime,
shift.number,
shift.title,
undefined,
// Only use externalEventId for iCloud/Google (stable UIDs)
Expand Down
3 changes: 3 additions & 0 deletions app/api/presets/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function PATCH(
title,
startTime,
endTime,
number,
color,
notes,
isSecondary,
Expand Down Expand Up @@ -117,6 +118,7 @@ export async function PATCH(
title,
startTime: isAllDay ? "00:00" : startTime,
endTime: isAllDay ? "23:59" : endTime,
number: number !== undefined ? number : "",
color,
notes: notes || null,
isSecondary: isSecondary !== undefined ? isSecondary : undefined,
Expand All @@ -136,6 +138,7 @@ export async function PATCH(
endTime: isAllDay ? "23:59" : endTime,
color,
notes: notes || null,
number: number !== undefined ? number : "",
isAllDay: isAllDay !== undefined ? isAllDay : undefined,
updatedAt: new Date(),
})
Expand Down
2 changes: 2 additions & 0 deletions app/api/presets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export async function POST(request: NextRequest) {
title,
startTime,
endTime,
number,
color,
notes,
isSecondary,
Expand Down Expand Up @@ -122,6 +123,7 @@ export async function POST(request: NextRequest) {
title,
startTime: isAllDay ? "00:00" : startTime,
endTime: isAllDay ? "23:59" : endTime,
number,
color: color || "#3b82f6",
notes: notes || null,
isSecondary: isSecondary || false,
Expand Down
3 changes: 3 additions & 0 deletions app/api/shifts/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { calendars, shifts } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { getSessionUser } from "@/lib/auth/sessions";
import { canViewCalendar, canEditCalendar } from "@/lib/auth/permissions";
import { number } from "better-auth";

// GET single shift
export async function GET(
Expand All @@ -21,6 +22,7 @@ export async function GET(
date: shifts.date,
startTime: shifts.startTime,
endTime: shifts.endTime,
number: shifts.number,
title: shifts.title,
color: shifts.color,
notes: shifts.notes,
Expand Down Expand Up @@ -139,6 +141,7 @@ export async function PUT(
date: body.date ? new Date(body.date) : existingShift.date,
startTime: body.startTime ?? existingShift.startTime,
endTime: body.endTime ?? existingShift.endTime,
number: body.number ?? existingShift.number,
title: body.title ?? existingShift.title,
color: body.color ?? existingShift.color,
notes: body.notes ?? existingShift.notes,
Expand Down
4 changes: 4 additions & 0 deletions app/api/shifts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { eq, and, gte, lte, or, isNull } from "drizzle-orm";
import { getSessionUser } from "@/lib/auth/sessions";
import { canViewCalendar, canEditCalendar } from "@/lib/auth/permissions";
import { parseLocalDate } from "@/lib/date-utils";
import { number } from "better-auth";

// GET shifts for a calendar (with optional date filter)
export async function GET(request: Request) {
Expand Down Expand Up @@ -51,6 +52,7 @@ export async function GET(request: Request) {
date: shifts.date,
startTime: shifts.startTime,
endTime: shifts.endTime,
number: shifts.number,
title: shifts.title,
color: shifts.color,
notes: shifts.notes,
Expand Down Expand Up @@ -124,6 +126,7 @@ export async function POST(request: Request) {
startTime,
endTime,
title,
number,
color,
notes,
presetId,
Expand Down Expand Up @@ -181,6 +184,7 @@ export async function POST(request: Request) {
startTime: isAllDay ? "00:00" : startTime,
endTime: isAllDay ? "23:59" : endTime,
title,
number: number || "",
color: color || "#3b82f6",
notes: notes || null,
isAllDay: isAllDay || false,
Expand Down
2 changes: 2 additions & 0 deletions app/api/shifts/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
startOfYear,
endOfYear,
} from "date-fns";
import { number } from "better-auth";

// GET shift statistics for a calendar
export async function GET(request: Request) {
Expand Down Expand Up @@ -88,6 +89,7 @@ export async function GET(request: Request) {
date: shifts.date,
startTime: shifts.startTime,
endTime: shifts.endTime,
number: shifts.number,
isAllDay: shifts.isAllDay,
})
.from(shifts)
Expand Down
11 changes: 11 additions & 0 deletions app/docs/ApiDocs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";

import SwaggerUI from "swagger-ui-react";

export default function ApiDocs() {
return (
<SwaggerUI
url="openapi.json"
/>
);
}
104 changes: 104 additions & 0 deletions app/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
import { ThemeProvider } from "@/components/theme-provider";
import { ThemedToaster } from "@/components/themed-toaster";
import { AuthProvider } from "@/components/auth-provider";
import { PublicConfigProvider } from "@/components/public-config-provider";
import { QueryProvider } from "@/components/query-provider";
import { getPublicConfig } from "@/lib/public-config";
import "../globals.css";
import "swagger-ui-react/swagger-ui.css";
import "swagger-ui-themes/themes/3.x/theme-material.css";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "BetterShift",
description: "Your favorite shift management application!",
applicationName: "BetterShift",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "BetterShift",
},
formatDetection: {
telephone: false,
},
icons: {
icon: [
{ url: "/ios/32.png", sizes: "32x32", type: "image/png" },
{
url: "/android/android-launchericon-192-192.png",
sizes: "192x192",
type: "image/png",
},
{
url: "/android/android-launchericon-512-512.png",
sizes: "512x512",
type: "image/png",
},
],
apple: [{ url: "/ios/180.png", sizes: "180x180", type: "image/png" }],
},
};

export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }],
};

export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale();
const messages = await getMessages();
const publicConfig = getPublicConfig();

return (
<html lang={locale} suppressHydrationWarning>
<head>
{/* Inject public config for immediate client-side access (zero latency) */}
<script
dangerouslySetInnerHTML={{
__html: `window.__PUBLIC_CONFIG__=${JSON.stringify(publicConfig)};`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange={false}
>
<NextIntlClientProvider messages={messages} locale={locale}>
<QueryProvider>
<PublicConfigProvider initialConfig={publicConfig}>
<AuthProvider>{children}</AuthProvider>
</PublicConfigProvider>
</QueryProvider>
<ThemedToaster />
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
);
}
9 changes: 9 additions & 0 deletions app/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import ApiDocs from "./ApiDocs";

export default function DocsPage() {
return (
<main>
<ApiDocs />
</main>
);
}
46 changes: 37 additions & 9 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,18 +376,45 @@ function HomeContent() {
}
};

// Handle shift submit (create or update)
const handleShiftSubmit = async (formData: ShiftFormData) => {
if (editingShift) {
// Update existing shift
await updateShiftHook(editingShift.id, formData);
setEditingShift(undefined);
refetchShifts();
if (editingShift) {
const rangeChanged = formData.startDay !== formData.endDay;

if (rangeChanged) {
// Delete old shift
await deleteShiftHook(editingShift.id);

// Create one shift per day in the range
if (formData.startDay && formData.endDay) {
const currentDay = new Date(formData.startDay);
const endDay = new Date(formData.endDay);

while (currentDay <= endDay) {
await createShiftHook({
...formData,
date: currentDay.toISOString().slice(0, 10),
startDay: undefined,
endDay: undefined,
});

currentDay.setDate(currentDay.getDate() + 1);
}
}
} else {
// Create new shift
await shiftActions.handleShiftSubmit(formData);
// Normal update
await updateShiftHook(editingShift.id, {
...formData,
date: formData.startDay || formData.date,
});
}
};

setEditingShift(undefined);
refetchShifts();
} else {
// Create new shift(s)
await shiftActions.handleShiftSubmit(formData);
}
};

// Compare mode handlers
const handleCompareClick = () => {
Expand Down Expand Up @@ -492,6 +519,7 @@ function HomeContent() {
startTime: preset.startTime,
endTime: preset.endTime,
title: preset.title,
number: preset.number,
color: preset.color,
notes: preset.notes || "",
presetId: preset.id,
Expand Down
Loading