feat: fix Docker deployment, OAuth redirects, event editing, and UI improvements

- Fix Docker build: resolve hooks-rules-of-hooks in GridTaskBlock, add Suspense
  boundary for useSearchParams, fix non-root user home dir and prisma binary path
- Add comprehensive DB migrations for missing columns/tables (WeeklyGoal,
  CachedCalendarEvent, User settings, SomedayList sync fields, Task subtasks)
- Fix Outlook OAuth redirect using NEXTAUTH_URL instead of request.url
- Fix CalendarEventModal preserving existing event dates (support flat format)
- Fix header z-index layering so goal text doesn't block hover controls
- Convert font size selects to free-form text inputs, add dayHeaderGap setting
- Add goal 2-line clamping with 500px max width
- Fix weekend colors not applying to date elements

v1.6.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-24 23:18:13 +01:00
parent 279d1a4c49
commit f0672e5c68
15 changed files with 410 additions and 113 deletions

View File

@ -23,30 +23,40 @@ FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Install OpenSSL for Prisma and curl for healthcheck
RUN apt-get update && apt-get install -y openssl curl && rm -rf /var/lib/apt/lists/*
# Create non-root user
# Create non-root user with a writable home directory
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN adduser --system --uid 1001 --home /home/nextjs nextjs
# Copy built output and dependencies
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/next.config.js ./
# Create logs directory
# Set correct permissions
RUN mkdir -p /app/logs && chown -R nextjs:nodejs /app
# Copy built output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
# Copy Prisma CLI and generated client from builder so npx doesn't re-download
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Copy entrypoint script
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3001/api/auth/session || exit 1
CMD curl -f http://localhost:3000/api/auth/session || exit 1
CMD ["npm", "run", "start"]
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

View File

@ -2,6 +2,7 @@ version: '3.8'
services:
app:
build: .
image: my-weekly-todo:latest
ports:
- "13000:3000"
@ -10,7 +11,7 @@ services:
depends_on:
- db
volumes:
- app_logs:/app/logs
- ./logs:/app/logs
restart: unless-stopped
db:

View File

@ -2,6 +2,7 @@
const { version } = require('./package.json');
const nextConfig = {
output: 'standalone',
env: {
NEXT_PUBLIC_APP_VERSION: version,
},

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.5.0",
"version": "1.6.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -0,0 +1,117 @@
-- AlterTable: Add missing User settings columns
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "goalFallbackType" TEXT NOT NULL DEFAULT 'quote';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "goalDefaultSentence" TEXT NOT NULL DEFAULT 'goal of the week';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "dateLayout" TEXT NOT NULL DEFAULT 'right';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "dateAlignment" TEXT NOT NULL DEFAULT 'center';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "hourLabelFormat" TEXT NOT NULL DEFAULT 'short';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showSubHourSlots" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "allDayPosition" TEXT NOT NULL DEFAULT 'below';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwColor" TEXT DEFAULT '#333333';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontFamily" TEXT DEFAULT 'Inter';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontSize" TEXT DEFAULT '1.125rem';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontWeight" TEXT DEFAULT '700';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearColor" TEXT DEFAULT '#333333';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontFamily" TEXT DEFAULT 'Inter';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontSize" TEXT DEFAULT '1.125rem';
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontWeight" TEXT DEFAULT '700';
-- AlterTable: Add missing SomedayList columns
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalProvider" TEXT;
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3);
-- AlterTable: Add missing Task columns
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "parentTaskId" TEXT;
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
-- CreateTable: WeeklyGoal
CREATE TABLE IF NOT EXISTS "WeeklyGoal" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"weekStart" TIMESTAMP(3) NOT NULL,
"text" TEXT NOT NULL DEFAULT 'your goal of this week',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WeeklyGoal_pkey" PRIMARY KEY ("id")
);
-- CreateTable: CachedCalendarEvent
CREATE TABLE IF NOT EXISTS "CachedCalendarEvent" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"calendarId" TEXT NOT NULL,
"calendarTitle" TEXT NOT NULL,
"calendarColor" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"startDateTime" TIMESTAMP(3),
"startDate" TEXT,
"endDateTime" TIMESTAMP(3),
"endDate" TEXT,
"weekStart" TIMESTAMP(3) NOT NULL,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CachedCalendarEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndexes for WeeklyGoal
CREATE UNIQUE INDEX IF NOT EXISTS "WeeklyGoal_userId_weekStart_key" ON "WeeklyGoal"("userId", "weekStart");
CREATE INDEX IF NOT EXISTS "WeeklyGoal_userId_idx" ON "WeeklyGoal"("userId");
-- CreateIndexes for CachedCalendarEvent
CREATE UNIQUE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_externalId_provider_key" ON "CachedCalendarEvent"("userId", "externalId", "provider");
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDateTime_idx" ON "CachedCalendarEvent"("userId", "startDateTime");
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDate_idx" ON "CachedCalendarEvent"("userId", "startDate");
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_connectionId_weekStart_idx" ON "CachedCalendarEvent"("connectionId", "weekStart");
-- CreateIndex for Task
CREATE INDEX IF NOT EXISTS "Task_parentTaskId_idx" ON "Task"("parentTaskId");
CREATE INDEX IF NOT EXISTS "Task_userId_deletedAt_idx" ON "Task"("userId", "deletedAt");
-- AddForeignKeys (idempotent)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'Task_parentTaskId_fkey'
) THEN
ALTER TABLE "Task" ADD CONSTRAINT "Task_parentTaskId_fkey" FOREIGN KEY ("parentTaskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'WeeklyGoal_userId_fkey'
) THEN
ALTER TABLE "WeeklyGoal" ADD CONSTRAINT "WeeklyGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'CachedCalendarEvent_connectionId_fkey'
) THEN
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "CalendarConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'CachedCalendarEvent_userId_fkey'
) THEN
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Fix Task_somedayListId_fkey to use SET NULL on delete (idempotent)
ALTER TABLE "Task" DROP CONSTRAINT IF EXISTS "Task_somedayListId_fkey";
ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,100 @@
-- AlterTable: Add missing SomedayList columns
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalProvider" TEXT;
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3);
-- AlterTable: Add missing Task columns
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "parentTaskId" TEXT;
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
-- CreateTable: WeeklyGoal
CREATE TABLE IF NOT EXISTS "WeeklyGoal" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"weekStart" TIMESTAMP(3) NOT NULL,
"text" TEXT NOT NULL DEFAULT 'your goal of this week',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WeeklyGoal_pkey" PRIMARY KEY ("id")
);
-- CreateTable: CachedCalendarEvent
CREATE TABLE IF NOT EXISTS "CachedCalendarEvent" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"calendarId" TEXT NOT NULL,
"calendarTitle" TEXT NOT NULL,
"calendarColor" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"startDateTime" TIMESTAMP(3),
"startDate" TEXT,
"endDateTime" TIMESTAMP(3),
"endDate" TEXT,
"weekStart" TIMESTAMP(3) NOT NULL,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CachedCalendarEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndexes for WeeklyGoal
CREATE UNIQUE INDEX IF NOT EXISTS "WeeklyGoal_userId_weekStart_key" ON "WeeklyGoal"("userId", "weekStart");
CREATE INDEX IF NOT EXISTS "WeeklyGoal_userId_idx" ON "WeeklyGoal"("userId");
-- CreateIndexes for CachedCalendarEvent
CREATE UNIQUE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_externalId_provider_key" ON "CachedCalendarEvent"("userId", "externalId", "provider");
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDateTime_idx" ON "CachedCalendarEvent"("userId", "startDateTime");
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDate_idx" ON "CachedCalendarEvent"("userId", "startDate");
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_connectionId_weekStart_idx" ON "CachedCalendarEvent"("connectionId", "weekStart");
-- CreateIndexes for Task
CREATE INDEX IF NOT EXISTS "Task_parentTaskId_idx" ON "Task"("parentTaskId");
CREATE INDEX IF NOT EXISTS "Task_userId_deletedAt_idx" ON "Task"("userId", "deletedAt");
-- AddForeignKeys (idempotent)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'Task_parentTaskId_fkey'
) THEN
ALTER TABLE "Task" ADD CONSTRAINT "Task_parentTaskId_fkey" FOREIGN KEY ("parentTaskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'WeeklyGoal_userId_fkey'
) THEN
ALTER TABLE "WeeklyGoal" ADD CONSTRAINT "WeeklyGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'CachedCalendarEvent_connectionId_fkey'
) THEN
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "CalendarConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'CachedCalendarEvent_userId_fkey'
) THEN
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Fix Task_somedayListId_fkey to use SET NULL on delete
ALTER TABLE "Task" DROP CONSTRAINT IF EXISTS "Task_somedayListId_fkey";
ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;

10
scripts/docker-entrypoint.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
set -e
# Run migrations using the local prisma binary
echo "Running database migrations..."
node node_modules/prisma/build/index.js migrate deploy
# Start the application
echo "Starting application..."
exec "$@"

View File

@ -5,11 +5,13 @@ import { prisma } from '@/lib/prisma';
import { getTokens, getUserCalendars } from '@/lib/outlook-calendar';
export async function GET(request: NextRequest) {
const baseUrl = process.env.NEXTAUTH_URL || request.url;
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.redirect(new URL('/auth/login', request.url));
return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
const { searchParams } = new URL(request.url);
@ -18,11 +20,11 @@ export async function GET(request: NextRequest) {
if (error) {
console.error('Outlook OAuth error:', error);
return NextResponse.redirect(new URL('/?error=outlook_auth_failed', request.url));
return NextResponse.redirect(new URL('/?error=outlook_auth_failed', baseUrl));
}
if (!code) {
return NextResponse.redirect(new URL('/?error=no_code', request.url));
return NextResponse.redirect(new URL('/?error=no_code', baseUrl));
}
// Exchange code for tokens
@ -39,7 +41,7 @@ export async function GET(request: NextRequest) {
});
if (!user) {
return NextResponse.redirect(new URL('/auth/login', request.url));
return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
// Calculate expiry date
@ -89,13 +91,13 @@ export async function GET(request: NextRequest) {
// Redirect to a client-side page that re-establishes the session
// Direct redirects from Microsoft OAuth may lose the session cookie (SameSite policy)
const redirectUrl = new URL('/auth/oauth-complete', request.url);
const redirectUrl = new URL('/auth/oauth-complete', baseUrl);
redirectUrl.searchParams.set('provider', 'outlook');
redirectUrl.searchParams.set('status', 'connected');
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('Error in Outlook callback:', error);
const redirectUrl = new URL('/auth/oauth-complete', request.url);
const redirectUrl = new URL('/auth/oauth-complete', baseUrl);
redirectUrl.searchParams.set('provider', 'outlook');
redirectUrl.searchParams.set('status', 'error');
redirectUrl.searchParams.set('message', 'outlook_callback_failed');

View File

@ -8,7 +8,8 @@ export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.redirect(new URL('/auth/login', request.url));
const baseUrl = process.env.NEXTAUTH_URL || request.url;
return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
const authUrl = getAuthUrl();

View File

@ -1,10 +1,10 @@
"use client";
import { useEffect } from "react";
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
export default function OAuthCompletePage() {
function OAuthCompleteContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { update } = useSession();
@ -52,3 +52,21 @@ export default function OAuthCompletePage() {
</div>
);
}
export default function OAuthCompletePage() {
return (
<Suspense fallback={
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontFamily: "Inter, sans-serif",
}}>
<p style={{ color: "#6b7280" }}>Loading...</p>
</div>
}>
<OAuthCompleteContent />
</Suspense>
);
}

View File

@ -645,13 +645,11 @@ h3 {
}
/* Weekend Styling */
.weekly-day-column.is-sat .weekly-day-name,
.weekly-day-column.is-sat .weekly-day-date {
.weekly-day-column.is-sat .weekly-day-name {
color: var(--weekly-weekend-sat, #666);
}
.weekly-day-column.is-sun .weekly-day-name,
.weekly-day-column.is-sun .weekly-day-date {
.weekly-day-column.is-sun .weekly-day-name {
color: var(--weekly-weekend-sun, #dc2626);
}

View File

@ -42,7 +42,9 @@ export default function CalendarEventModal({
// Default duration: 1 hour.
const getInitialStart = () => {
// Support both nested (start.dateTime) and flat (startTime) event formats
if (event?.start?.dateTime) return new Date(event.start.dateTime);
if (event?.startTime) return new Date(event.startTime);
if (initialDate) {
const d = new Date(initialDate);
if (initialStartTime) {
@ -59,7 +61,9 @@ export default function CalendarEventModal({
};
const getInitialEnd = () => {
// Support both nested (end.dateTime) and flat (endTime) event formats
if (event?.end?.dateTime) return new Date(event.end.dateTime);
if (event?.endTime) return new Date(event.endTime);
const start = getInitialStart();
return new Date(start.getTime() + 60 * 60 * 1000); // +1 hour
};

View File

@ -105,29 +105,8 @@ export function GridTaskBlock({
}, 0);
};
if (!task.startTime) return null;
const [startHour, startMinute] = task.startTime.split(":").map(Number);
const startMinutes = (startHour - workingHoursStart) * 60 + startMinute;
// Calculate top offset
// Calculate dimensions (needed by resize useEffect, must be before early return)
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
const topOffset = startMinutes * pixelsPerMinute;
// Calculate height
const duration = task.duration || 15; // default 15m if not set
const baseHeight = duration * pixelsPerMinute;
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
// Handlers for resizing
const onResizeStart = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setIsResizing(true);
resizeStartY.current = e.clientY;
resizeStartHeight.current = baseHeight;
document.body.style.cursor = "ns-resize";
};
useEffect(() => {
const onResizeMove = (e: MouseEvent) => {
@ -139,7 +118,7 @@ export function GridTaskBlock({
setResizeHeight(newHeight);
};
const onResizeEnd = (e: MouseEvent) => {
const onResizeEnd = () => {
if (!isResizing) return;
setIsResizing(false);
document.body.style.cursor = "";
@ -161,6 +140,29 @@ export function GridTaskBlock({
};
}, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration]);
if (!task.startTime) return null;
const [startHour, startMinute] = task.startTime.split(":").map(Number);
const startMinutes = (startHour - workingHoursStart) * 60 + startMinute;
// Calculate top offset
const topOffset = startMinutes * pixelsPerMinute;
// Calculate height
const duration = task.duration || 15; // default 15m if not set
const baseHeight = duration * pixelsPerMinute;
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
// Handlers for resizing
const onResizeStart = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setIsResizing(true);
resizeStartY.current = e.clientY;
resizeStartHeight.current = baseHeight;
document.body.style.cursor = "ns-resize";
};
return (
<div

View File

@ -607,6 +607,7 @@ export default function WeeklyView() {
yearFontSize?: string;
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
quoteSourceUrls?: string[];
}>({
name: session?.user?.name || "",
@ -3414,7 +3415,7 @@ export default function WeeklyView() {
{/* Refactored Header: Left, Center, Right */}
<header className="group flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200">
{/* LEFT SECTION: Slot Duration & Days to Show */}
<div className="flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
<div className="flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Slot Duration */}
{showTimeGrid && (
<div
@ -3504,7 +3505,7 @@ export default function WeeklyView() {
</div>
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group">
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group" style={{ zIndex: 0 }}>
{/* Week & Year */}
<div className="whitespace-nowrap flex items-center gap-2">
{syncError ? (
@ -3582,7 +3583,13 @@ export default function WeeklyView() {
fontSize: profile.goalFontSize || undefined,
fontWeight: profile.goalFontWeight || undefined,
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
filter: "brightness(var(--weekly-goal-brightness, 1))"
filter: "brightness(var(--weekly-goal-brightness, 1))",
maxWidth: "500px",
textAlign: "center" as const,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical" as const,
}}
>
{showNextTask
@ -3622,7 +3629,7 @@ export default function WeeklyView() {
</div>
{/* RIGHT SECTION: Navigation & Tools */}
<div className="flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
<div className="flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Undo/Redo */}
<button
onClick={handleUndo}
@ -3931,7 +3938,7 @@ export default function WeeklyView() {
: profile.dateLayout === "below"
? "column"
: "row",
gap: profile.dateAlignment === "tight" ? "2px" : "4px",
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
}}
>
{profile.dateLayout === "left" && (
@ -6273,6 +6280,7 @@ interface SettingsSidebarProps {
yearFontSize?: string;
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
quoteSourceUrls: string[];
}) => void;
quoteSourceUrls?: string[];
@ -6627,6 +6635,7 @@ function SettingsSidebar({
yearFontSize?: string;
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
quoteSourceUrls?: string[];
}>({
name: "",
@ -6751,6 +6760,18 @@ function SettingsSidebar({
goalScope: profile.goalScope,
dateLayout: profile.dateLayout,
dateAlignment: profile.dateAlignment,
goalFontFamily: profile.goalFontFamily,
goalFontSize: profile.goalFontSize,
goalFontWeight: profile.goalFontWeight,
cwColor: profile.cwColor,
cwFontFamily: profile.cwFontFamily,
cwFontSize: profile.cwFontSize,
cwFontWeight: profile.cwFontWeight,
yearColor: profile.yearColor,
yearFontFamily: profile.yearFontFamily,
yearFontSize: profile.yearFontSize,
yearFontWeight: profile.yearFontWeight,
dayHeaderGap: profile.dayHeaderGap,
} as any);
}, [profile, showTimeGrid, cellDuration, viewStyle, fontSize, showNextTask, showSomeday, showAllDay, showSchedule]);
@ -8399,7 +8420,8 @@ function SettingsSidebar({
</option>
))}
</select>
<select
<input
type="text"
value={profile.headlineFontSize || "1.25rem"}
onChange={(e) =>
setProfile({
@ -8407,6 +8429,7 @@ function SettingsSidebar({
headlineFontSize: e.target.value,
})
}
placeholder="1.25rem"
className="weekly-input"
style={{
width: "100%",
@ -8417,13 +8440,7 @@ function SettingsSidebar({
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="1rem">Small 16px</option>
<option value="1.25rem">Normal 20px</option>
<option value="1.5rem">Large 24px</option>
<option value="1.75rem">XL 28px</option>
<option value="2rem">Huge 32px</option>
</select>
/>
<select
value={profile.headlineFontWeight || "900"}
onChange={(e) =>
@ -8516,11 +8533,13 @@ function SettingsSidebar({
</option>
))}
</select>
<select
<input
type="text"
value={profile.dateFontSize || "0.65rem"}
onChange={(e) =>
setProfile({ ...profile, dateFontSize: e.target.value })
}
placeholder="0.65rem"
className="weekly-input"
style={{
width: "100%",
@ -8531,13 +8550,7 @@ function SettingsSidebar({
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="0.55rem">XS 9px</option>
<option value="0.65rem">Normal 10px</option>
<option value="0.75rem">Small 12px</option>
<option value="0.85rem">Medium 14px</option>
<option value="1rem">Large 16px</option>
</select>
/>
<select
value={profile.dateFontWeight || "400"}
onChange={(e) =>
@ -8630,7 +8643,8 @@ function SettingsSidebar({
</option>
))}
</select>
<select
<input
type="text"
value={profile.taskFontSize || "0.9rem"}
onChange={(e) =>
setProfile({
@ -8639,6 +8653,7 @@ function SettingsSidebar({
timeTaskFontSize: e.target.value,
})
}
placeholder="0.9rem"
className="weekly-input"
style={{
width: "100%",
@ -8649,13 +8664,7 @@ function SettingsSidebar({
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="0.75rem">Small 12px</option>
<option value="0.9rem">Normal 14px</option>
<option value="1rem">Medium 16px</option>
<option value="1.1rem">Large 18px</option>
<option value="1.25rem">XL 20px</option>
</select>
/>
<select
value={profile.taskFontWeight || "400"}
onChange={(e) =>
@ -8743,7 +8752,8 @@ function SettingsSidebar({
</option>
))}
</select>
<select
<input
type="text"
value={profile.eventFontSize || "0.85rem"}
onChange={(e) =>
setProfile({
@ -8751,6 +8761,7 @@ function SettingsSidebar({
eventFontSize: e.target.value,
})
}
placeholder="0.85rem"
className="weekly-input"
style={{
width: "100%",
@ -8761,12 +8772,7 @@ function SettingsSidebar({
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="0.75rem">Small 12px</option>
<option value="0.85rem">Normal 14px</option>
<option value="0.95rem">Medium 16px</option>
<option value="1.05rem">Large 18px</option>
</select>
/>
<select
value={profile.eventFontWeight || "400"}
onChange={(e) =>
@ -8853,11 +8859,13 @@ function SettingsSidebar({
</option>
))}
</select>
<select
<input
type="text"
value={profile.goalFontSize || "1rem"}
onChange={(e) =>
setProfile({ ...profile, goalFontSize: e.target.value })
}
placeholder="1rem"
className="weekly-input"
style={{
width: "100%",
@ -8868,12 +8876,7 @@ function SettingsSidebar({
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="0.85rem">Small 14px</option>
<option value="1rem">Default 16px</option>
<option value="1.15rem">Large 18px</option>
<option value="1.3rem">Huge 20px</option>
</select>
/>
<select
value={profile.goalFontWeight || "400"}
onChange={(e) =>
@ -8902,6 +8905,46 @@ function SettingsSidebar({
</div>
</div>
{/* Day / Weekday Gap */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Day / Weekday Gap
</label>
<input
type="text"
value={profile.dayHeaderGap || "0.35em"}
onChange={(e) =>
setProfile({ ...profile, dayHeaderGap: e.target.value })
}
placeholder="0.35em"
className="weekly-input"
style={{
width: "100%",
padding: "8px",
fontSize: "0.9rem",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
/>
</div>
{/* Calendar Week Font */}
<div
style={{
@ -8948,21 +8991,16 @@ function SettingsSidebar({
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
<select
<input
type="text"
value={profile.cwFontSize || "1.125rem"}
onChange={(e) =>
setProfile({ ...profile, cwFontSize: e.target.value })
}
placeholder="1.125rem"
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="0.75rem">Small 12px</option>
<option value="0.875rem">Normal 14px</option>
<option value="1rem">Medium 16px</option>
<option value="1.125rem">Large 18px</option>
<option value="1.25rem">XL 20px</option>
<option value="1.5rem">Huge 24px</option>
</select>
/>
<select
value={profile.cwFontWeight || "700"}
onChange={(e) =>
@ -9027,21 +9065,16 @@ function SettingsSidebar({
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
<select
<input
type="text"
value={profile.yearFontSize || "1.125rem"}
onChange={(e) =>
setProfile({ ...profile, yearFontSize: e.target.value })
}
placeholder="1.125rem"
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="0.75rem">Small 12px</option>
<option value="0.875rem">Normal 14px</option>
<option value="1rem">Medium 16px</option>
<option value="1.125rem">Large 18px</option>
<option value="1.25rem">XL 20px</option>
<option value="1.5rem">Huge 24px</option>
</select>
/>
<select
value={profile.yearFontWeight || "700"}
onChange={(e) =>

File diff suppressed because one or more lines are too long