chore(release): merge dev to main for v1.9.0

This commit is contained in:
mARTin 2026-03-02 07:56:23 +01:00
commit 99e2e9bac8
21 changed files with 1379 additions and 149 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.8.7",
"version": "1.9.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,62 @@
-- User: add accountNumber (auto-incrementing unique identifier)
CREATE SEQUENCE IF NOT EXISTS "User_accountNumber_seq";
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "accountNumber" INTEGER NOT NULL DEFAULT nextval('"User_accountNumber_seq"');
ALTER SEQUENCE "User_accountNumber_seq" OWNED BY "User"."accountNumber";
-- Populate existing rows with sequential numbers
DO $$
DECLARE
r RECORD;
counter INTEGER := 1;
BEGIN
FOR r IN SELECT id FROM "User" ORDER BY "createdAt" ASC LOOP
UPDATE "User" SET "accountNumber" = counter WHERE id = r.id;
counter := counter + 1;
END LOOP;
-- Set sequence to continue after the highest assigned number
PERFORM setval('"User_accountNumber_seq"', COALESCE((SELECT MAX("accountNumber") FROM "User"), 0));
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "User_accountNumber_key" ON "User"("accountNumber");
-- User: add quick settings preferences
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showCompletedTasks" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showLines" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "startDayOffset" INTEGER NOT NULL DEFAULT -1;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "quoteSourceUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CachedCalendarEvent: add url, recurringEventId, isRecurring
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "url" TEXT;
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "recurringEventId" TEXT;
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "isRecurring" BOOLEAN NOT NULL DEFAULT false;
-- Task: add projectId
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "projectId" TEXT;
-- Project model
CREATE TABLE IF NOT EXISTS "Project" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"icon" TEXT,
"color" TEXT,
"description" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- Indexes
CREATE INDEX IF NOT EXISTS "Project_userId_idx" ON "Project"("userId");
CREATE INDEX IF NOT EXISTS "Task_userId_projectId_idx" ON "Task"("userId", "projectId");
-- Foreign keys
DO $$ BEGIN
ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

View File

@ -10,6 +10,7 @@ datasource db {
model User {
id String @id @default(cuid())
accountNumber Int @unique @default(autoincrement())
email String @unique
passwordHash String?
name String?
@ -87,8 +88,13 @@ model User {
yearFontSize String? @default("1.5rem")
yearFontWeight String? @default("700")
showTaskCheckboxes Boolean @default(false)
showCompletedTasks Boolean @default(true)
showLines Boolean @default(true)
startDayOffset Int @default(-1)
quoteSourceUrls String[] @default([])
emailVerificationCode String?
accounts Account[]
projects Project[]
cachedCalendarEvents CachedCalendarEvent[]
calendarConnections CalendarConnection[]
sessions Session[]
@ -159,10 +165,12 @@ model Task {
lastSyncedAt DateTime?
deletedAt DateTime?
parentTaskId String?
projectId String?
somedaySlotIndex Int?
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
subTasks Task[] @relation("SubTasks")
somedayList SomedayList? @relation(fields: [somedayListId], references: [id])
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, dayOfWeek])
@ -170,6 +178,7 @@ model Task {
@@index([userId, somedayListId])
@@index([userId, externalId])
@@index([userId, deletedAt])
@@index([userId, projectId])
@@index([parentTaskId])
}
@ -215,6 +224,9 @@ model CachedCalendarEvent {
title String
description String?
location String?
url String?
recurringEventId String?
isRecurring Boolean @default(false)
startDateTime DateTime?
startDate String?
endDateTime DateTime?
@ -244,3 +256,19 @@ model WeeklyGoal {
@@unique([userId, weekStart])
@@index([userId])
}
model Project {
id String @id @default(cuid())
userId String
name String
icon String?
color String?
description String?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@@index([userId])
}

View File

@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state'); // User email passed from start route
const state = searchParams.get('state'); // User CUID passed from start route
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
@ -22,16 +22,16 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl));
}
// Get user from session or state parameter
const userEmail = session?.user?.email || state;
// Get user by session ID or state parameter (which now contains CUID, not email)
const userId = (session?.user as any)?.id || state;
if (!userEmail) {
if (!userId) {
return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl));
}
// Find user in database
// Find user in database by ID (works regardless of signup email)
const user = await prisma.user.findUnique({
where: { email: userEmail }
where: { id: userId }
});
if (!user) {
@ -145,7 +145,7 @@ export async function GET(request: NextRequest) {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userEmail,
providerAccountId: user.id,
access_token: tokens.access_token || '',
refresh_token: tokens.refresh_token || null,
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,

View File

@ -10,7 +10,8 @@ export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
const userId = (session?.user as any)?.id;
if (!userId) {
const baseUrl = process.env.NEXTAUTH_URL || request.url;
return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
@ -42,7 +43,7 @@ export async function GET(request: NextRequest) {
'https://www.googleapis.com/auth/tasks',
],
prompt: 'consent',
state: session.user.email, // Pass user email to identify in callback
state: userId, // Pass user CUID to identify in callback (not email)
});
// Redirect the user to Google's consent screen

View File

@ -36,9 +36,10 @@ export async function GET(request: NextRequest) {
// Fetch user's calendars to store initial list
const calendars = await getUserCalendars(accessToken);
const user = await prisma.user.findUnique({
where: { email: session.user.email }
});
const userId = (session.user as any).id;
const user = userId
? await prisma.user.findUnique({ where: { id: userId } })
: await prisma.user.findUnique({ where: { email: session.user.email } });
if (!user) {
return NextResponse.redirect(new URL('/auth/login', baseUrl));

View File

@ -7,7 +7,8 @@ import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConn
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
const userId = (session?.user as any)?.id;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@ -18,7 +19,7 @@ export async function POST(request: NextRequest) {
const timeMaxDate = new Date(timeMax ?? Date.now() + 7 * 24 * 60 * 60 * 1000);
const user = await prisma.user.findUnique({
where: { email: session.user.email },
where: { id: userId },
include: { calendarConnections: true },
});

View File

@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
const POPULAR_FONTS = [
{ name: "Inter", value: "Inter", category: "sans-serif" },
{ name: "Roboto", value: "Roboto", category: "sans-serif" },
{ name: "Open Sans", value: "Open Sans", category: "sans-serif" },
{ name: "Lato", value: "Lato", category: "sans-serif" },
{ name: "Montserrat", value: "Montserrat", category: "sans-serif" },
{ name: "Oswald", value: "Oswald", category: "sans-serif" },
{ name: "Raleway", value: "Raleway", category: "sans-serif" },
{ name: "Playfair Display", value: "Playfair Display", category: "serif" },
{ name: "Merriweather", value: "Merriweather", category: "serif" },
{ name: "Nunito", value: "Nunito", category: "sans-serif" },
{ name: "Dancing Script", value: "Dancing Script", category: "handwriting" },
{ name: "Pacifico", value: "Pacifico", category: "handwriting" },
{ name: "Poppins", value: "Poppins", category: "sans-serif" },
{ name: "Source Sans Pro", value: "Source Sans Pro", category: "sans-serif" },
{ name: "Ubuntu", value: "Ubuntu", category: "sans-serif" },
{ name: "Rubik", value: "Rubik", category: "sans-serif" },
{ name: "Work Sans", value: "Work Sans", category: "sans-serif" },
{ name: "Quicksand", value: "Quicksand", category: "sans-serif" },
{ name: "Josefin Sans", value: "Josefin Sans", category: "sans-serif" },
{ name: "Libre Baskerville", value: "Libre Baskerville", category: "serif" },
{ name: "Crimson Text", value: "Crimson Text", category: "serif" },
{ name: "Bitter", value: "Bitter", category: "serif" },
{ name: "Archivo", value: "Archivo", category: "sans-serif" },
{ name: "DM Sans", value: "DM Sans", category: "sans-serif" },
{ name: "Space Grotesk", value: "Space Grotesk", category: "sans-serif" },
{ name: "Outfit", value: "Outfit", category: "sans-serif" },
{ name: "Sora", value: "Sora", category: "sans-serif" },
{ name: "Caveat", value: "Caveat", category: "handwriting" },
{ name: "Comfortaa", value: "Comfortaa", category: "display" },
{ name: "Barlow", value: "Barlow", category: "sans-serif" },
{ name: "Karla", value: "Karla", category: "sans-serif" },
{ name: "Manrope", value: "Manrope", category: "sans-serif" },
{ name: "Lexend", value: "Lexend", category: "sans-serif" },
{ name: "Roboto Slab", value: "Roboto Slab", category: "serif" },
{ name: "PT Serif", value: "PT Serif", category: "serif" },
{ name: "Noto Sans", value: "Noto Sans", category: "sans-serif" },
{ name: "Fira Sans", value: "Fira Sans", category: "sans-serif" },
{ name: "IBM Plex Sans", value: "IBM Plex Sans", category: "sans-serif" },
{ name: "Cabin", value: "Cabin", category: "sans-serif" },
{ name: "Inconsolata", value: "Inconsolata", category: "monospace" },
];
let cachedGoogleFonts: any[] | null = null;
let cacheTimestamp = 0;
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || '';
const apiKey = process.env.GOOGLE_FONTS_API_KEY;
let fonts = POPULAR_FONTS;
// Try Google Fonts API if key is configured
if (apiKey && (!cachedGoogleFonts || Date.now() - cacheTimestamp > CACHE_TTL)) {
try {
const res = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}&sort=popularity`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) {
const data = await res.json();
cachedGoogleFonts = (data.items || []).map((f: any) => ({
name: f.family,
value: f.family,
category: f.category,
}));
cacheTimestamp = Date.now();
}
} catch {
// Keep fallback
}
}
if (cachedGoogleFonts) {
fonts = cachedGoogleFonts;
}
if (query) {
fonts = fonts.filter((f) =>
f.name.toLowerCase().includes(query.toLowerCase())
);
}
return NextResponse.json({ fonts: fonts.slice(0, 100) });
}

View File

@ -109,6 +109,11 @@ export async function GET(req: Request) {
}
}
// POST delegates to PUT for client compatibility
export async function POST(req: Request) {
return PUT(req);
}
export async function PUT(req: Request) {
try {
const session = await getServerSession(authOptions);

View File

@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// GET - List all projects for authenticated user
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const projects = await prisma.project.findMany({
where: { userId },
orderBy: { order: 'asc' },
include: {
_count: {
select: { tasks: true },
},
},
});
return NextResponse.json({ projects });
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json(
{ error: 'Failed to fetch projects' },
{ status: 500 }
);
}
}
// POST - Create a new project
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const body = await request.json();
const { name, icon, color, description } = body;
if (!name) {
return NextResponse.json(
{ error: 'Project name is required' },
{ status: 400 }
);
}
// Set order to be after the last project
const lastProject = await prisma.project.findFirst({
where: { userId },
orderBy: { order: 'desc' },
select: { order: true },
});
const project = await prisma.project.create({
data: {
name,
icon: icon || null,
color: color || null,
description: description || null,
order: (lastProject?.order ?? -1) + 1,
userId,
},
});
return NextResponse.json({ project });
} catch (error) {
console.error('Error creating project:', error);
return NextResponse.json(
{ error: 'Failed to create project' },
{ status: 500 }
);
}
}
// PATCH - Update a project
export async function PATCH(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Project ID is required' },
{ status: 400 }
);
}
// Validate ownership
const existingProject = await prisma.project.findFirst({
where: { id, userId },
});
if (!existingProject) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
const body = await request.json();
const { name, icon, color, description, order } = body;
const project = await prisma.project.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(icon !== undefined && { icon: icon || null }),
...(color !== undefined && { color: color || null }),
...(description !== undefined && { description: description || null }),
...(order !== undefined && { order: parseInt(order) }),
},
});
return NextResponse.json({ project });
} catch (error) {
console.error('Error updating project:', error);
return NextResponse.json(
{ error: 'Failed to update project' },
{ status: 500 }
);
}
}
// DELETE - Delete a project (tasks remain, their projectId becomes null)
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Project ID is required' },
{ status: 400 }
);
}
// Validate ownership
const existingProject = await prisma.project.findFirst({
where: { id, userId },
});
if (!existingProject) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
// Nullify projectId on all tasks belonging to this project
await prisma.task.updateMany({
where: { projectId: id },
data: { projectId: null },
});
await prisma.project.delete({
where: { id },
});
return NextResponse.json({ message: 'Project deleted' });
} catch (error) {
console.error('Error deleting project:', error);
return NextResponse.json(
{ error: 'Failed to delete project' },
{ status: 500 }
);
}
}

View File

@ -144,6 +144,9 @@ export async function GET(request: NextRequest) {
where: includeDeleted ? {} : { deletedAt: null },
orderBy: { order: 'asc' },
},
project: {
select: { id: true, name: true, icon: true, color: true },
},
},
orderBy: [
{ dayOfWeek: 'asc' },
@ -195,7 +198,7 @@ export async function POST(request: NextRequest) {
const userId = (session.user as any).id;
const body = await request.json();
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId } = body;
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId, projectId } = body;
let { isRolling } = body;
const { isRecurring } = body;
@ -234,6 +237,7 @@ export async function POST(request: NextRequest) {
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null,
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
parentTaskId: parentTaskId || null,
...(projectId !== undefined && { projectId: projectId || null }),
},
});
@ -266,7 +270,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json();
const { id } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId, projectId } = body;
if (!id) {
return NextResponse.json(
@ -351,7 +355,8 @@ export async function PATCH(request: NextRequest) {
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }),
...(restore === true && { deletedAt: null }),
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null })
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }),
...(projectId !== undefined && { projectId: projectId || null }),
},
});

View File

@ -78,6 +78,11 @@ export async function GET(request: NextRequest) {
yearFontWeight: true,
yearColor: true,
dayHeaderGap: true,
showCompletedTasks: true,
showLines: true,
startDayOffset: true,
quoteSourceUrls: true,
accountNumber: true,
createdAt: true
}
});
@ -118,7 +123,8 @@ export async function PATCH(request: NextRequest) {
hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
showTaskCheckboxes, dayHeaderGap
showTaskCheckboxes, dayHeaderGap,
showCompletedTasks, showLines, startDayOffset, quoteSourceUrls
} = body;
const updateData: any = {
@ -187,6 +193,10 @@ export async function PATCH(request: NextRequest) {
...(yearFontWeight !== undefined && { yearFontWeight }),
...(yearColor !== undefined && { yearColor }),
...(dayHeaderGap !== undefined && { dayHeaderGap }),
...(showCompletedTasks !== undefined && { showCompletedTasks }),
...(showLines !== undefined && { showLines }),
...(startDayOffset !== undefined && { startDayOffset }),
...(quoteSourceUrls !== undefined && { quoteSourceUrls }),
};
if (password && password.trim() !== "") {
updateData.passwordHash = await bcrypt.hash(password, 10);
@ -263,6 +273,11 @@ export async function PATCH(request: NextRequest) {
yearFontWeight: true,
yearColor: true,
dayHeaderGap: true,
showCompletedTasks: true,
showLines: true,
startDayOffset: true,
quoteSourceUrls: true,
accountNumber: true,
}
});

View File

@ -3263,6 +3263,12 @@ h3 {
flex-shrink: 0;
}
/* CRITICAL: Make all header sections visible on mobile (no hover on touch devices) */
.weekly-header-controls {
opacity: 1 !important;
transition: none !important;
}
/* Fix overlap on mobile by removing absolute positioning */
.weekly-header .absolute {
position: static !important;

View File

@ -1,13 +1,25 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
const inter = Inter({ subsets: ['latin'] });
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export const metadata: Metadata = {
title: 'My Weekly To Do List',
description: 'A simple, designy to-do app.',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'My Weekly To Do List',
},
};
export default function RootLayout({

View File

@ -0,0 +1,190 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { ChevronDown, Search } from "lucide-react";
const POPULAR_FONTS = [
"Inter", "Roboto", "Open Sans", "Lato", "Montserrat", "Oswald",
"Raleway", "Playfair Display", "Merriweather", "Nunito",
"Dancing Script", "Pacifico", "Poppins", "Source Sans Pro",
"Ubuntu", "Rubik", "Work Sans", "Quicksand", "Josefin Sans",
"Libre Baskerville", "Crimson Text", "Bitter", "Archivo",
"DM Sans", "Space Grotesk", "Outfit", "Sora", "Caveat",
"Comfortaa", "Barlow", "Karla", "Manrope", "Lexend",
"Roboto Slab", "PT Serif", "Noto Sans", "Fira Sans",
"IBM Plex Sans", "Cabin", "Inconsolata",
];
interface FontPickerProps {
value: string;
onChange: (fontName: string) => void;
darkMode?: boolean;
}
export default function FontPicker({ value, onChange, darkMode }: FontPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [allFonts, setAllFonts] = useState<string[]>(POPULAR_FONTS);
const [loadedFonts, setLoadedFonts] = useState<Set<string>>(new Set(["Inter"]));
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Try to fetch from Google Fonts API for extended list
useEffect(() => {
const fetchFonts = async () => {
try {
const res = await fetch("/api/fonts");
if (res.ok) {
const data = await res.json();
if (data.fonts?.length > 0) {
setAllFonts(data.fonts.map((f: any) => f.value || f.name || f));
}
}
} catch {
// Keep popular fonts as fallback
}
};
fetchFonts();
}, []);
// Load font for preview
const loadFont = (fontName: string) => {
if (loadedFonts.has(fontName) || fontName === "Inter") return;
const id = `font-preview-${fontName.replace(/\s+/g, "-")}`;
if (!document.getElementById(id)) {
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, "+")}:wght@400;700&display=swap`;
document.head.appendChild(link);
}
setLoadedFonts((prev) => new Set(prev).add(fontName));
};
// Load selected font
useEffect(() => {
if (value) loadFont(value);
}, [value]);
// Close on click outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Focus input on open
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const filtered = query
? allFonts.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
: allFonts;
const bg = darkMode ? "#1f2937" : "#fff";
const border = darkMode ? "#374151" : "#e5e7eb";
const text = darkMode ? "#e5e7eb" : "#333";
const hoverBg = darkMode ? "#374151" : "#f0f9ff";
return (
<div ref={containerRef} style={{ position: "relative" }}>
<button
onClick={() => setIsOpen(!isOpen)}
style={{
fontFamily: value,
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 8px",
border: `1px solid ${border}`,
borderRadius: "6px",
background: bg,
color: text,
cursor: "pointer",
fontSize: "0.8rem",
width: "100%",
justifyContent: "space-between",
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{value || "Select font"}
</span>
<ChevronDown size={14} />
</button>
{isOpen && (
<div
style={{
position: "absolute",
zIndex: 1000,
background: bg,
border: `1px solid ${border}`,
borderRadius: "8px",
width: "260px",
boxShadow: "0 4px 20px rgba(0,0,0,0.15)",
top: "100%",
left: 0,
marginTop: "4px",
}}
>
<div style={{ display: "flex", alignItems: "center", borderBottom: `1px solid ${border}`, padding: "6px 8px", gap: "6px" }}>
<Search size={14} style={{ color: "#999", flexShrink: 0 }} />
<input
ref={inputRef}
type="text"
placeholder="Search fonts..."
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{
padding: "4px",
width: "100%",
border: "none",
outline: "none",
background: "transparent",
color: text,
fontSize: "0.8rem",
}}
/>
</div>
<div style={{ maxHeight: "250px", overflowY: "auto" }}>
{filtered.slice(0, 50).map((font) => {
loadFont(font);
return (
<div
key={font}
onClick={() => {
onChange(font);
setIsOpen(false);
setQuery("");
}}
onMouseEnter={() => loadFont(font)}
style={{
padding: "6px 12px",
cursor: "pointer",
fontFamily: font,
fontSize: "0.85rem",
color: text,
background: font === value ? hoverBg : "transparent",
borderLeft: font === value ? "3px solid #0ea5e9" : "3px solid transparent",
}}
>
{font}
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: "12px", textAlign: "center", color: "#999", fontSize: "0.8rem" }}>
No fonts found
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,205 @@
"use client";
import { X, Type, Space, CheckSquare, Calendar, Minus } from "lucide-react";
interface QuickSettingsProps {
fontSize: string;
onFontSizeChange: (size: string) => void;
spacing: string;
onSpacingChange: (spacing: string) => void;
showCompleted: boolean;
onShowCompletedChange: (show: boolean) => void;
startDayOffset: number;
onStartDayOffsetChange: (offset: number) => void;
showLines: boolean;
onShowLinesChange: (show: boolean) => void;
isOpen: boolean;
onClose: () => void;
darkMode?: boolean;
}
export default function QuickSettingsSidebar({
fontSize,
onFontSizeChange,
spacing,
onSpacingChange,
showCompleted,
onShowCompletedChange,
startDayOffset,
onStartDayOffsetChange,
showLines,
onShowLinesChange,
isOpen,
onClose,
darkMode,
}: QuickSettingsProps) {
const bg = darkMode ? "#1f2937" : "#ffffff";
const text = darkMode ? "#e5e7eb" : "#333333";
const border = darkMode ? "#374151" : "#e5e7eb";
const accent = "#0ea5e9";
const btnBg = darkMode ? "#374151" : "#f3f4f6";
const btnActiveBg = accent;
const btnActiveText = "#ffffff";
const labelColor = darkMode ? "#9ca3af" : "#6b7280";
const sizes = ["S", "M", "L"];
const SegmentedButton = ({
options,
value,
onChange,
}: {
options: string[];
value: string;
onChange: (v: string) => void;
}) => (
<div style={{ display: "flex", gap: "2px", background: btnBg, borderRadius: "8px", padding: "2px" }}>
{options.map((opt) => (
<button
key={opt}
onClick={() => onChange(opt)}
style={{
flex: 1,
padding: "6px 10px",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: value === opt ? 600 : 400,
background: value === opt ? btnActiveBg : "transparent",
color: value === opt ? btnActiveText : text,
transition: "all 0.15s ease",
}}
>
{opt}
</button>
))}
</div>
);
const Toggle = ({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) => (
<button
onClick={() => onChange(!checked)}
style={{
width: "36px",
height: "20px",
borderRadius: "10px",
border: "none",
background: checked ? accent : (darkMode ? "#4b5563" : "#d1d5db"),
cursor: "pointer",
position: "relative",
transition: "background 0.2s ease",
flexShrink: 0,
}}
>
<div
style={{
width: "16px",
height: "16px",
borderRadius: "50%",
background: "#fff",
position: "absolute",
top: "2px",
left: checked ? "18px" : "2px",
transition: "left 0.2s ease",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
);
return (
<>
{/* Backdrop */}
{isOpen && (
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.1)",
zIndex: 998,
}}
/>
)}
{/* Panel */}
<div
style={{
position: "fixed",
right: 0,
top: 0,
bottom: 0,
width: isOpen ? "220px" : "0",
overflow: "hidden",
background: bg,
borderLeft: isOpen ? `1px solid ${border}` : "none",
transition: "width 0.2s ease",
zIndex: 999,
display: "flex",
flexDirection: "column",
}}
>
<div style={{ padding: "16px", display: "flex", flexDirection: "column", gap: "20px", overflowY: "auto", flex: 1 }}>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600, fontSize: "0.85rem", color: text }}>Quick Settings</span>
<button
onClick={onClose}
style={{ background: "none", border: "none", cursor: "pointer", color: labelColor, padding: "2px" }}
>
<X size={16} />
</button>
</div>
{/* Font Size */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<Type size={14} style={{ color: labelColor }} />
<span style={{ fontSize: "0.75rem", color: labelColor }}>Font Size</span>
</div>
<SegmentedButton options={sizes} value={fontSize} onChange={onFontSizeChange} />
</div>
{/* Spacing */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<Space size={14} style={{ color: labelColor }} />
<span style={{ fontSize: "0.75rem", color: labelColor }}>Spacing</span>
</div>
<SegmentedButton options={sizes} value={spacing} onChange={onSpacingChange} />
</div>
{/* Show Completed */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<CheckSquare size={14} style={{ color: labelColor }} />
<span style={{ fontSize: "0.75rem", color: labelColor }}>Show Completed</span>
</div>
<Toggle checked={showCompleted} onChange={onShowCompletedChange} />
</div>
{/* Start Day */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<Calendar size={14} style={{ color: labelColor }} />
<span style={{ fontSize: "0.75rem", color: labelColor }}>Start View</span>
</div>
<SegmentedButton
options={["Yesterday", "Today"]}
value={startDayOffset === -1 ? "Yesterday" : "Today"}
onChange={(v) => onStartDayOffsetChange(v === "Yesterday" ? -1 : 0)}
/>
</div>
{/* Show Lines */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Minus size={14} style={{ color: labelColor }} />
<span style={{ fontSize: "0.75rem", color: labelColor }}>Show Lines</span>
</div>
<Toggle checked={showLines} onChange={onShowLinesChange} />
</div>
</div>
</div>
</>
);
}

View File

@ -1299,15 +1299,17 @@ export default function WeeklyView() {
const saveGoal = async (newGoal: string) => {
setGoal(newGoal);
try {
await fetch("/api/goal", {
method: "POST",
const res = await fetch("/api/goal", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
weekStart: goalDateKey,
goal: newGoal,
scope: profile.goalScope || "week",
text: newGoal,
}),
});
if (!res.ok) {
console.error("Goal save failed:", res.status);
}
} catch (error) {
console.error("Error saving goal:", error);
}
@ -1434,8 +1436,13 @@ export default function WeeklyView() {
setShowTimeGrid(data.user.showTimeGrid ?? true);
}
if (data.user.viewDays !== undefined) {
setViewDays(data.user.viewDays);
savedViewDaysRef.current = data.user.viewDays;
// Re-apply responsive constraints after loading saved preference
const width = window.innerWidth;
if (width <= 480) setViewDays(1);
else if (width <= 768) setViewDays(3);
else if (width <= 1024) setViewDays(Math.min(data.user.viewDays, 5));
else setViewDays(data.user.viewDays);
}
if (data.user.cellDuration !== undefined)
setCellDuration(data.user.cellDuration as CellDuration);
@ -2064,6 +2071,48 @@ export default function WeeklyView() {
setCurrentWeekStart(d);
};
// Touch swipe navigation for mobile
useEffect(() => {
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
const handleTouchStart = (e: TouchEvent) => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
};
const handleTouchEnd = (e: TouchEvent) => {
touchEndX = e.changedTouches[0].screenX;
touchEndY = e.changedTouches[0].screenY;
const diffX = touchEndX - touchStartX;
const diffY = touchEndY - touchStartY;
// Only trigger if horizontal swipe is dominant and > 80px
if (Math.abs(diffX) > 80 && Math.abs(diffX) > Math.abs(diffY) * 1.5) {
if (diffX > 0) {
// Swipe right → go to previous day
goToPrevDay();
} else {
// Swipe left → go to next day
goToNextDay();
}
}
};
const container = document.querySelector('.weekly-container') as HTMLElement | null;
if (container) {
container.addEventListener('touchstart', handleTouchStart as EventListener, { passive: true });
container.addEventListener('touchend', handleTouchEnd as EventListener, { passive: true });
}
return () => {
if (container) {
container.removeEventListener('touchstart', handleTouchStart as EventListener);
container.removeEventListener('touchend', handleTouchEnd as EventListener);
}
};
}, [currentWeekStart]); // Re-attach when week changes so closures are fresh
const executeImport = async (provider: "google" | "apple" | "outlook") => {
setImportProvider(provider);
setIsImportModalOpen(true);
@ -3593,7 +3642,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" style={{ zIndex: 1 }}>
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Slot Duration */}
{showTimeGrid && (
<div
@ -3808,7 +3857,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" style={{ zIndex: 1 }}>
<div className="weekly-header-controls flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Undo/Redo */}
<button
onClick={handleUndo}

View File

@ -12,6 +12,8 @@ export interface AppleCalendarEvent {
description?: string;
location?: string;
url?: string;
recurringEventId?: string;
isRecurring?: boolean;
}
export interface AppleCalendar {
@ -161,13 +163,15 @@ export const getUpcomingEvents = async (
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
parsedEvents.push({
id: `${exEvent.uid}-${exStart.toISOString()}`,
id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`,
title: exEvent.summary || 'Untitled Event',
startDate: exIsAllDay ? exStart.toISOString().slice(0, 10) : exStart.toISOString(),
endDate: exIsAllDay ? exEnd.toISOString().slice(0, 10) : exEnd.toISOString(),
description: exEvent.description,
location: exEvent.location,
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: exEvent.uid,
isRecurring: true,
});
}
});
@ -194,13 +198,15 @@ export const getUpcomingEvents = async (
if (exceptionDates.has(occStart.toISOString())) continue;
parsedEvents.push({
id: `${event.uid}-${occStart.toISOString()}`,
id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`,
title: event.summary || 'Untitled Event',
startDate: isAllDayRecurring ? occStart.toISOString().slice(0, 10) : occStart.toISOString(),
endDate: isAllDayRecurring ? occEnd.toISOString().slice(0, 10) : occEnd.toISOString(),
description: event.description,
location: event.location,
url: vevent.getFirstPropertyValue('url')?.toString() || undefined
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: event.uid,
isRecurring: true,
});
}
} catch (expandErr: any) {
@ -215,13 +221,13 @@ export const getUpcomingEvents = async (
if (end.getTime() < minTime || start.getTime() > maxTime) return;
parsedEvents.push({
id: event.uid || eventObj.url,
id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`,
title: event.summary || 'Untitled Event',
startDate: isAllDay ? start.toISOString().slice(0, 10) : start.toISOString(),
endDate: isAllDay ? end.toISOString().slice(0, 10) : end.toISOString(),
description: event.description,
location: event.location,
url: vevent.getFirstPropertyValue('url')?.toString() || undefined
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
});
}
});
@ -372,6 +378,22 @@ export const updateEvent = async (
const client = createClient(email, appSpecificPassword);
await client.login();
let targetObject: any = null;
// Try O(1) path first with new caldav:: ID format
const parsed = parseCaldavId(eventId);
if (parsed) {
// Fetch single object directly by URL (O(1))
const objects = await client.fetchCalendarObjects({
calendar: { url: calendarUrl } as any,
objectUrls: [parsed.objectUrl],
});
targetObject = objects?.[0] || null;
if (!targetObject) {
throw new Error('Event not found on server at URL: ' + parsed.objectUrl);
}
} else {
// Legacy fallback: O(n) scan for old-format IDs
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
@ -379,110 +401,23 @@ export const updateEvent = async (
throw new Error(`Calendar not found: ${calendarUrl}`);
}
// Parse IDs - implementation specific
// Our getUpcomingEvents returns ID as "UID-filename" or just UID if filename not avail?
// Actually getUpcomingEvents returns `${event.uid}-${occStart}` for recurring
// or `event.uid || eventObj.url` for simple.
// We need the original object URL (filename) to update via DAV.
// If we only have UID, we have to search for it.
// Strategy: Fetch all objects in range (expensive?) or try to find by UID?
// CALDAV allows query by UID.
// Let's assume eventId passed in is the UID for now, or we can extract it.
const uid = eventId.split('-')[0]; // Simple heuristic
// Use calendarQuery to find the object by UID
// Valid PROP query for getetag and calendar-data
// Tsdav doesn't expose a simple "findOneByUID".
// We'll traverse, assuming we can filter.
// Actually, `fetchCalendarObjects` allows filters.
/*
NOTE: tsdav filter support is XML based.
Constructing a filter for UID:
<filter>
<comp-filter name="VCALENDAR">
<comp-filter name="VEVENT">
<prop-filter name="UID">
<text-match collation="i;octet">${uid}</text-match>
</prop-filter>
</comp-filter>
</comp-filter>
</filter>
*/
// Since constructing that XML object via tsdav's types might be complex,
// let's try a simpler approach if possible, or build the object.
// For now, let's assume we can fetch objects and find the match in memory if the range isn't too huge?
// No, better to search.
// Let's try to pass a simpler time range around the event if we knew the time.
// If not, we scan.
// Given we are editing, we usually have the original time.
// But `eventData` only has NEW data. We might need valid old data.
// Let's rely on client logic to pass us enough info?
// Wait, `updateCalendarEvent` in `calendar-events.ts` calls us.
// Let's assume for MVP we fetch objects in a wide range? No.
// Let's use `fetchCalendarObjects` without timerange -> fetches all? Dangerous for large cals.
// Alternative: The `eventId` from our `getUpcomingEvents` was `event.uid` (or derived).
// Let's try to match by UID.
// Workaround: We will use a time range if provided in inputs (unlikely for existing?)
// Actually, we don't have the OLD time in the `updateEvent` signature here easily unless we fetch.
// Let's try to standard approach: Fetch all from now - 1 month to + 1 year?
// Or just valid `calendar-query` with UID filter.
// Since constructing the filter manually is hard in this context without xml-js helpers handy...
// I will try to fetch the object by its URL if the ID *was* the URL.
// In `getUpcomingEvents`, for simple events, we returned `id: event.uid || eventObj.url`.
// If it's a URL (ends in .ics), we can just use it.
let objectUrl = '';
const etag = '';
const existingIcal = '';
if (eventId.endsWith('.ics')) {
// It looks like a filename/url
objectUrl = eventId;
// But we need the full URL or relative?
// tsdav expects `calendarObject.url`.
}
// If we can't easily find it by ID, we might fail.
// Let's assume for this iteration we try to find it.
const uid = eventId.split('-')[0];
const allObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
// No time range = all? limit?
// Let's check if we can filter by UID in filter object
});
// This fetches ALL objects (headers only usually?).
// `fetchCalendarObjects` does report usually.
// Find matching UID
const targetObject = allObjects.find(obj => {
// obj.data contains iCal string if expanded?
// If not expanded, we might need to fetch data.
// By default `fetchCalendarObjects` usually fetches props specified.
targetObject = allObjects.find(obj => {
if (obj.data) {
return obj.data.includes(`UID:${uid}`);
}
return obj.url.includes(uid); // Fallback assumption
return obj.url.includes(uid);
});
if (!targetObject) {
throw new Error('Event not found on server');
}
}
// Now we have the object.
// Parse existing iCal to preserve other fields
@ -580,6 +515,20 @@ export const updateEvent = async (
/**
* Delete an event
*/
/**
* Parse the new caldav:: ID format to extract the object URL and UID.
* Format: "caldav::<objectUrl>::<uid>[::occurrence-iso]"
* Returns null for legacy format IDs.
*/
function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | null {
if (!eventId.startsWith('caldav::')) return null;
const parts = eventId.split('::');
if (parts.length >= 3) {
return { objectUrl: parts[1], uid: parts[2] };
}
return null;
}
export const deleteEvent = async (
email: string,
appSpecificPassword: string,
@ -590,6 +539,18 @@ export const deleteEvent = async (
const client = createClient(email, appSpecificPassword);
await client.login();
// Try O(1) path first with new caldav:: ID format
const parsed = parseCaldavId(eventId);
if (parsed) {
await client.deleteObject({
url: parsed.objectUrl,
etag: undefined,
} as any);
console.log('[APPLE CALENDAR] Event deleted via direct URL (O(1))');
return;
}
// Legacy fallback: O(n) scan for old-format IDs
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
@ -599,9 +560,6 @@ export const deleteEvent = async (
const uid = eventId.split('-')[0];
// Find object - similar logic to update
// Optimal: Pass the object URL in the ID in getUpcomingEvents to allow O(1) delete/update
const allObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
});
@ -618,13 +576,12 @@ export const deleteEvent = async (
return;
}
// Same issue as update - use deleteObject directly
await client.deleteObject({
url: targetObject.url,
etag: targetObject.etag
} as any); // Cast to any to bypass type definition mismatch
} as any);
console.log('[APPLE CALENDAR] Event deleted successfully');
console.log('[APPLE CALENDAR] Event deleted via legacy scan');
} catch (error) {
console.error('[APPLE CALENDAR] Error deleting event:', error);

View File

@ -68,6 +68,10 @@ export async function readCachedEvents(
id: row.externalId,
title: row.title,
description: row.description,
location: row.location,
url: row.url,
recurringEventId: row.recurringEventId,
isRecurring: row.isRecurring,
startTime: row.startDateTime?.toISOString() ?? row.startDate ?? '',
endTime: row.endDateTime?.toISOString() ?? row.endDate ?? '',
source: row.provider as 'google' | 'apple' | 'outlook',
@ -116,6 +120,9 @@ export async function refreshConnectionCache(
title: ev.title,
description: ev.description ?? null,
location: ev.location ?? null,
url: ev.url ?? null,
recurringEventId: ev.recurringEventId ?? null,
isRecurring: ev.isRecurring ?? false,
startDateTime: ev.start.dateTime ? new Date(ev.start.dateTime) : null,
startDate: ev.start.date ?? null,
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
@ -167,6 +174,9 @@ export async function upsertCachedEvent(
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
url: event.url ?? null,
recurringEventId: event.recurringEventId ?? null,
isRecurring: event.isRecurring ?? false,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
@ -180,6 +190,9 @@ export async function upsertCachedEvent(
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
url: event.url ?? null,
recurringEventId: event.recurringEventId ?? null,
isRecurring: event.isRecurring ?? false,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,

View File

@ -21,6 +21,8 @@ export interface CalendarEvent {
location?: string;
url?: string;
recurrence?: string;
recurringEventId?: string;
isRecurring?: boolean;
source: 'google' | 'apple' | 'outlook';
calendarId: string;
calendarTitle: string;
@ -361,6 +363,8 @@ export const getCalendarEvents = async (
},
location: event.location,
url: event.url,
recurringEventId: event.recurringEventId,
isRecurring: event.isRecurring,
source: 'apple' as const,
calendarId,
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',

383
src/lib/quotes.ts Normal file
View File

@ -0,0 +1,383 @@
// quotes.ts - Curated quotes collection and utility functions
export interface LocalQuote {
text: string;
author: string;
language: "de" | "en";
tags: string[];
}
export const PRESET_QUOTE_SOURCES = [
{
name: "ZenQuotes",
url: "https://zenquotes.io/api/random",
language: "en",
tags: ["motivation", "wisdom", "life"],
},
{
name: "Quotable",
url: "https://api.quotable.io/random",
language: "en",
tags: ["motivation", "wisdom", "success", "life"],
},
{
name: "Forismatic",
url: "https://api.forismatic.com/api/1.0/?method=getQuote&format=json&lang=de",
language: "de",
tags: ["motivation", "wisdom", "life"],
},
{
name: "Type.fit",
url: "https://type.fit/api/quotes",
language: "en",
tags: ["motivation", "wisdom"],
},
] as const;
export const LOCAL_QUOTES: LocalQuote[] = [
// --- German Quotes ---
{
text: "Es ist nicht genug zu wissen, man muss auch anwenden; es ist nicht genug zu wollen, man muss auch tun.",
author: "Johann Wolfgang von Goethe",
language: "de",
tags: ["motivation", "work", "perseverance"],
},
{
text: "Wer immer tut, was er schon kann, bleibt immer das, was er schon ist.",
author: "Henry Ford",
language: "de",
tags: ["motivation", "success", "perseverance"],
},
{
text: "Der Zweck des Lebens ist das Leben selbst.",
author: "Johann Wolfgang von Goethe",
language: "de",
tags: ["life", "wisdom"],
},
{
text: "Man muss das Unmogliche versuchen, um das Mogliche zu erreichen.",
author: "Hermann Hesse",
language: "de",
tags: ["motivation", "perseverance"],
},
{
text: "Wer nicht kann, was er will, muss wollen, was er kann.",
author: "Friedrich Schiller",
language: "de",
tags: ["wisdom", "perseverance", "life"],
},
{
text: "Es hort doch jeder nur, was er versteht.",
author: "Johann Wolfgang von Goethe",
language: "de",
tags: ["wisdom", "life"],
},
{
text: "Ohne Musik ware das Leben ein Irrtum.",
author: "Friedrich Nietzsche",
language: "de",
tags: ["life", "creativity"],
},
{
text: "Wege entstehen dadurch, dass man sie geht.",
author: "Franz Kafka",
language: "de",
tags: ["motivation", "perseverance", "life"],
},
{
text: "Der Mensch ist nichts anderes als wozu er sich macht.",
author: "Jean-Paul Sartre",
language: "de",
tags: ["wisdom", "life", "motivation"],
},
{
text: "Wer kampft, kann verlieren. Wer nicht kampft, hat schon verloren.",
author: "Bertolt Brecht",
language: "de",
tags: ["motivation", "perseverance", "success"],
},
{
text: "Die Grenzen meiner Sprache bedeuten die Grenzen meiner Welt.",
author: "Ludwig Wittgenstein",
language: "de",
tags: ["wisdom", "creativity"],
},
{
text: "Was mich nicht umbringt, macht mich starker.",
author: "Friedrich Nietzsche",
language: "de",
tags: ["perseverance", "motivation"],
},
{
text: "Phantasie ist wichtiger als Wissen, denn Wissen ist begrenzt.",
author: "Albert Einstein",
language: "de",
tags: ["creativity", "wisdom"],
},
{
text: "Nur wer sein Ziel kennt, findet den Weg.",
author: "Laozi",
language: "de",
tags: ["wisdom", "motivation", "success"],
},
{
text: "Die beste Zeit einen Baum zu pflanzen war vor zwanzig Jahren. Die zweitbeste Zeit ist jetzt.",
author: "Konfuzius",
language: "de",
tags: ["wisdom", "motivation", "work"],
},
{
text: "Auch aus Steinen, die einem in den Weg gelegt werden, kann man Schones bauen.",
author: "Johann Wolfgang von Goethe",
language: "de",
tags: ["perseverance", "creativity", "life"],
},
{
text: "Handle, ehe du gehandelt wirst.",
author: "Friedrich Schiller",
language: "de",
tags: ["motivation", "work"],
},
{
text: "Geduld ist das Vertrauen, dass alles kommt, wenn die Zeit reif ist.",
author: "Konfuzius",
language: "de",
tags: ["wisdom", "perseverance", "life"],
},
{
text: "Im Wesen der Musik liegt es, Freude zu machen.",
author: "Aristoteles",
language: "de",
tags: ["creativity", "life", "humor"],
},
{
text: "Lache nie uber die Dummheit der anderen. Sie ist deine Chance.",
author: "Winston Churchill",
language: "de",
tags: ["humor", "success", "wisdom"],
},
{
text: "Leben ist das, was passiert, wahrend du eifrig dabei bist, andere Plane zu machen.",
author: "John Lennon",
language: "de",
tags: ["life", "humor", "wisdom"],
},
{
text: "Der Langsamste, der sein Ziel nicht aus den Augen verliert, geht noch immer geschwinder als jener, der ohne Ziel umherirrt.",
author: "Gotthold Ephraim Lessing",
language: "de",
tags: ["perseverance", "motivation", "work"],
},
{
text: "Ein Tropfen Humor auf einen Zentner Verstand.",
author: "Friedrich Nietzsche",
language: "de",
tags: ["humor", "wisdom"],
},
// --- English Quotes ---
{
text: "The only way to do great work is to love what you do.",
author: "Steve Jobs",
language: "en",
tags: ["work", "motivation", "success"],
},
{
text: "Stay hungry, stay foolish.",
author: "Steve Jobs",
language: "en",
tags: ["motivation", "creativity"],
},
{
text: "Life is what happens when you are busy making other plans.",
author: "John Lennon",
language: "en",
tags: ["life", "wisdom"],
},
{
text: "In the middle of difficulty lies opportunity.",
author: "Albert Einstein",
language: "en",
tags: ["perseverance", "motivation", "success"],
},
{
text: "The unexamined life is not worth living.",
author: "Socrates",
language: "en",
tags: ["wisdom", "life"],
},
{
text: "Be yourself; everyone else is already taken.",
author: "Oscar Wilde",
language: "en",
tags: ["life", "wisdom", "humor"],
},
{
text: "To live is the rarest thing in the world. Most people exist, that is all.",
author: "Oscar Wilde",
language: "en",
tags: ["life", "wisdom"],
},
{
text: "The man who moves a mountain begins by carrying away small stones.",
author: "Konfuzius",
language: "en",
tags: ["perseverance", "motivation", "work"],
},
{
text: "It is not because things are difficult that we do not dare; it is because we do not dare that they are difficult.",
author: "Seneca",
language: "en",
tags: ["motivation", "perseverance", "wisdom"],
},
{
text: "The happiness of your life depends upon the quality of your thoughts.",
author: "Marcus Aurelius",
language: "en",
tags: ["wisdom", "life"],
},
{
text: "Waste no more time arguing about what a good man should be. Be one.",
author: "Marcus Aurelius",
language: "en",
tags: ["wisdom", "motivation", "work"],
},
{
text: "If you want to lift yourself up, lift up someone else.",
author: "Booker T. Washington",
language: "en",
tags: ["motivation", "wisdom", "life"],
},
{
text: "Darkness cannot drive out darkness; only light can do that.",
author: "Martin Luther King Jr.",
language: "en",
tags: ["wisdom", "life", "motivation"],
},
{
text: "The time is always right to do what is right.",
author: "Martin Luther King Jr.",
language: "en",
tags: ["motivation", "wisdom", "work"],
},
{
text: "There is no greater agony than bearing an untold story inside you.",
author: "Maya Angelou",
language: "en",
tags: ["creativity", "life"],
},
{
text: "We delight in the beauty of the butterfly, but rarely admit the changes it has gone through to achieve that beauty.",
author: "Maya Angelou",
language: "en",
tags: ["perseverance", "life", "wisdom"],
},
{
text: "The secret of getting ahead is getting started.",
author: "Mark Twain",
language: "en",
tags: ["motivation", "work", "success"],
},
{
text: "Whenever you find yourself on the side of the majority, it is time to pause and reflect.",
author: "Mark Twain",
language: "en",
tags: ["wisdom", "creativity"],
},
{
text: "Twenty years from now you will be more disappointed by the things you did not do than by the ones you did.",
author: "Mark Twain",
language: "en",
tags: ["motivation", "life", "perseverance"],
},
{
text: "It does not matter how slowly you go as long as you do not stop.",
author: "Konfuzius",
language: "en",
tags: ["perseverance", "motivation"],
},
{
text: "Success is not final, failure is not fatal: it is the courage to continue that counts.",
author: "Winston Churchill",
language: "en",
tags: ["perseverance", "success", "motivation"],
},
{
text: "The best time to plant a tree was 20 years ago. The second best time is now.",
author: "Chinese Proverb",
language: "en",
tags: ["motivation", "wisdom", "work"],
},
{
text: "Do what you can, with what you have, where you are.",
author: "Theodore Roosevelt",
language: "en",
tags: ["motivation", "work", "perseverance"],
},
{
text: "Creativity is intelligence having fun.",
author: "Albert Einstein",
language: "en",
tags: ["creativity", "humor", "wisdom"],
},
{
text: "Everything you can imagine is real.",
author: "Pablo Picasso",
language: "en",
tags: ["creativity", "motivation"],
},
{
text: "I have not failed. I have just found 10,000 ways that will not work.",
author: "Thomas Edison",
language: "en",
tags: ["perseverance", "humor", "success"],
},
{
text: "Well done is better than well said.",
author: "Benjamin Franklin",
language: "en",
tags: ["work", "motivation", "success"],
},
{
text: "You miss 100% of the shots you do not take.",
author: "Wayne Gretzky",
language: "en",
tags: ["motivation", "success"],
},
];
/**
* All unique tags available across the local quotes collection.
*/
export const AVAILABLE_QUOTE_TAGS: string[] = Array.from(
new Set(LOCAL_QUOTES.flatMap((q) => q.tags))
).sort();
/**
* Returns a random quote from the local collection, optionally filtered
* by language and/or tag.
*
* @param language - Filter by "de" or "en". If omitted, picks from all.
* @param tag - Filter by a specific tag (e.g. "motivation"). If omitted, no tag filter.
* @returns A random LocalQuote matching the filters, or a fallback if no match found.
*/
export function getRandomLocalQuote(
language?: string,
tag?: string
): LocalQuote {
let pool = LOCAL_QUOTES;
if (language) {
pool = pool.filter((q) => q.language === language);
}
if (tag) {
pool = pool.filter((q) => q.tags.includes(tag));
}
if (pool.length === 0) {
// Fallback: return a random quote from the full collection
return LOCAL_QUOTES[Math.floor(Math.random() * LOCAL_QUOTES.length)];
}
return pool[Math.floor(Math.random() * pool.length)];
}