Compare commits

..

No commits in common. "main" and "v1.99.2" have entirely different histories.

53 changed files with 1006 additions and 4415 deletions

6
.gitignore vendored
View File

@ -41,9 +41,3 @@ prisma/dev.db-journal
.next/
certificates
# Design reference files (not tracked)
docs/design/
# Dokumentationen (nicht tracken)
APP_ERKLÄRUNG.md

View File

@ -1,20 +0,0 @@
# Changelog
All notable changes to this project are documented in this file.
## [1.113.1] - 2026-07-08
### Changed
- CSS Variables panel: added the existing "Today Highlight", "Past Days", "Saturday" and
"Sunday" color controls as their own groups (bound to the same profile fields as the
standalone Element/Weekend Colors sections, so both stay in sync).
- CSS Variables panel: every row now shows the color swatch before its label.
## [1.113.0] - 2026-07-08
### Added
- Styling settings: "CSS Variables" panel with a color picker and description for every
customizable color token in `globals.css` (base colors, settings sidebar, design token
aliases, event colors, all-day chips).
- Styling settings: "Custom CSS" textarea to inject a personal stylesheet that loads after
the app's default styles and can override any rule.

View File

@ -1 +0,0 @@
2331610

View File

@ -1,27 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.findUnique({
where: { email: 'martin.bierschenk@gmail.com' }
});
if (!user) return;
// Delete all tasks in the target range created today
const res = await prisma.task.deleteMany({
where: {
userId: user.id,
scheduledDate: {
gte: new Date('2026-03-15T00:00:00Z'),
lte: new Date('2026-04-15T00:00:00Z')
},
createdAt: {
gte: new Date(new Date().setHours(0,0,0,0))
}
}
});
console.log(`Deleted ${res.count} tasks from martin`);
}
main().finally(() => prisma.$disconnect());

View File

@ -1,255 +0,0 @@
# Design Notes — todo.martin-bierschenk.de v2
> Visual reference: `Todo Calendar Redesign v2.html`
> Aesthetic: Minimal Scandinavian / TeuxDeux-style hairline editorial
> Goal: a calm, airy, paper-like weekly planner. **Restyle only — no logic, routing, data, sync, or component-API changes.**
---
## 0 · Rules of engagement (read first)
1. **Do not change behaviour.** Keep all existing hooks, server actions, NextAuth wiring, calendar sync, recurring-task logic, drag-and-drop, keyboard shortcuts, and component prop shapes exactly as they are.
2. **Touch JSX + CSS only.** New CSS variables / Tailwind tokens are fine; new state, new effects, new dependencies are not.
3. **One component per commit.** Visual restyles are easy to review (and revert) when scoped. Suggested order: tokens → layout shell → day grid → all-day strip → events → tasks (anyday) → left rail → right settings → header.
4. **Keep existing component file structure** (`WeeklyCalendarView.tsx`, `TaskList.tsx`, `CalendarEventModal.tsx`, etc.). Only edit them.
5. **Where this doc and the HTML mock disagree, the mock wins** for visuals. Where the mock and the existing code disagree on behaviour, the existing code wins.
---
## 1 · Design tokens
Add these as CSS custom properties (or extend `tailwind.config.js` `theme.extend`). All other colors should be derived from these.
### Color
keep the colors for now
**Calendar-source palette** (event chips, muted on purpose — they should not compete with the today-red):
| Source | Background | Border-left | Title color | Meta color |
|---------------|-------------|-------------|-------------|------------|
| Default/work | `#eef2f7` | `#6c87a8` | `#2b3f57` | `#6c87a8` |
| Family (`.fam`) | `#fbeef0` | `#b85a6a` | `#5a2530` | `#99536a` |
| Finance (`.fin`)| `#f6f0e2` | `#a88a3c` | `#4a3a14` | `#8a6f2a` |
| Dev/GitHub (`.dev`) | `#ecf3ed` | `#5a7a4a` | `#2c4527` | `#5a7a4a` |
| Note (`.note`) | transparent | none | `var(--ink-2)` | `var(--ink-4)` |
**All-day chip palette** (solid pills above the day headers):
| Variant | Background | Text |
|--------------|-------------|-----------|
| Default | `#b8b8b0` | `#fff` |
| Family (`.fam`) | `#e88a8a` | `#fff` |
| Special (`.special`, anniversaries, milestones) | `#f4d588` | `#6b4f10` |
### Typography
- **UI / sans**: `Geist`, fallback `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`
- **Editorial / serif**: `Fraunces` italic — used **only** for the small quote in the header bar
- **Mono / numerals**: `Geist Mono` if needed; otherwise rely on `font-variant-numeric: tabular-nums` for any clock/date/count
Body letter-spacing: `-0.005em` baseline.
| Role | Size | Weight | LH | Other |
|-----------------------|------|--------|-------|-------|
| Section h3 | 16px | 600 | 1.2 | |
| Day name (`.dn`) | 16px | 600 | 1 | uppercase, `letter-spacing: 0.04em` |
| Day name — today | 16px | 700 | 1 | color: `--accent` |
| Day date (`.dnum`) | 11px | 400 | 1 | uppercase, `tabular-nums`, color: `--ink-4` |
| Header KW (`.wk`) | 13px | 500 | — | `letter-spacing: 0.02em` |
| Header quote | 12px | 400 | — | Fraunces italic, `--ink-4` |
| Event title | 12px | 500 | 1.25 | |
| Event note title | 11.5px | 400 | 1.25 | |
| Event meta / time | 10.5px | 400 | — | `tabular-nums` |
| All-day chip | 11px | 500 | 1.2 | |
| List heading (`h4`) | 12px | 500 | — | uppercase, `letter-spacing: 0.04em` |
| List item | 12px | 400 | 1.35 | |
| Hour label | 10px | 400 | — | `tabular-nums`, color: `--ink-5` |
| Tasks tab | 12px | 500/600 | — | active is 600 |
| Tab counter chip | 10.5px | 500 | 1.4 | `tabular-nums` |
### Spacing & geometry
- Left rail: **56px** wide, fixed
- Right settings drawer: **360px** wide, slides in from the right (`transform: translateX(100%)` → `translateX(0)`, `cubic-bezier(.2,.8,.2,1)` 250ms)
- Permanent top header: **36px** tall, white, hairline bottom
- Day grid hour-row height: **56px**
- Day grid column gap: **20px**
- Day grid horizontal padding: **24px**
- Border radius: 4px (events), 6px (corner buttons, inputs), 78px (list cards, segmented pills), 10px (tasks tab pill container)
- All shadows are tiny: `0 1px 2px rgba(0,0,0,.06)` or `0 4px 16px rgba(0,0,0,.06)`. Never bigger.
- Hover surfaces: `rgba(0,0,0,.05)` for icons, `rgba(0,0,0,.025).04` for text hits
### Motion
- All transitions: `120180ms`, `cubic-bezier(.2,.8,.2,1)` for sliding panels, plain `ease` for hover
- The right drawer is the only large transform; everything else is opacity / background swaps
---
## 2 · Layout shell
```
┌─────────────────────────── header (36px, always visible) ───┐
│ KW 19 | 410. Mai 2026 "quote…" │
├──────┬────────────────────────────────────────────────┬─────┤
│ │ all-day strip │ │
│ left │────────────────────────────────────────────────│ rt │
│ rail │ day-headers row │ drw │
│ 56px │────────────────────────────────────────────────│ 360 │
│ │ day grid (hours × columns) │ px │
│ │────────────────────────────────────────────────│ off │
│ │ tasks (anyday) — warm grey, 6 white list cards │ scr │
└──────┴────────────────────────────────────────────────┴─────┘
```
- The **toolbar is gone**. Only two floating icon buttons remain: a sidebar-toggle top-left of the header, a search top-right.
- The right drawer is hidden by default. A small `18×64px` chevron tab on the right edge toggles it; the gear icon in the left rail also toggles it.
---
## 3 · Component-by-component restyle guide
### 3.1 Top header (`header`)
- `position: sticky`/`absolute`, `top: 0`, height 36px, white, hairline bottom
- Centered: `KW WW | 410. Mai 2026` • faint Fraunces italic quote
- Text colors: KW = `--ink-2` 13px/500; quote = `--ink-4` 12px italic; ellipsize at ~480px
### 3.2 Left rail (`lside`)
Order, top to bottom:
1. **Date nav cluster**` row, "Heute" pill (uppercase, hairline border), ` »` row
2. Hairline separator
3. **View flyout** — calendar icon; on hover, flyout reveals: Einfach / Kalender / Liste / Kanban / Ziel
4. **Day-count flyout** — grid icon; flyout: 1 / 2 / 3 / 5 / 7
5. **Zeitfenster flyout** — clock icon; flyout: 15m / 20m / 30m / 60m
6. Hairline separator
7. Action icons (vertical stack): plus, calendar-pick, projects, recurring, target, focus
8. Hairline separator
9. Undo, redo, sync, download, print
10. Hairline separator
11. **Light/dark toggle** (single sun/moon icon — toggles, not two icons)
12. **User avatar** at the bottom (`margin-top: auto`)
Icons are 16px, currentColor, in 36×36 hit-targets, `border-radius: 8px`, hover background `rgba(0,0,0,.05)`. Active state uses `--accent-soft` background + `--accent` color.
Flyouts pop **right** of the rail with a subtle pointer triangle.
### 3.3 All-day strip (`allday`)
- Grid: `56px [hour gutter pad] repeat(N, 1fr)`, column gap 20px, padding `10px 24px 0`
- Per-day column: `min-height: 56px`, vertical stack of chips, gap 3px
- Chips are **solid colored pills** (see palette table). No icons, no date pips inside.
- Today's column gets `background: var(--accent-soft)` with extra horizontal padding
- Small uppercase label "GANZTAGS" 9px in left gutter
### 3.4 Day headers (`days` + `dayh`)
- Grid identical to all-day strip (same column gaps for alignment)
- Each header: small **MAI 6** date on top, large **MITTWOCH** weekday name below, both uppercase
- 2px solid `--ink` underline below each header
- **Today**: weekday in `--accent` red 700, blue-grey `--accent-soft` background, centered, padding 6px 8px, 1px ink underline
- **Weekend (Sat/Sun)**: weekday red, underline red
### 3.5 Day grid body (`body`)
- **No vertical lines** between days — the column gap (20px) is the separator. This is the TeuxDeux move.
- Horizontal hour rules only (`--line` color, 1px), one per hour
- Hour labels in the left gutter, 10px, `--ink-5`, right-aligned, sitting on the rule (background-cut)
- **Today column** gets `background: var(--accent-soft)` (full height)
- **Now-line**: 1px `--accent` red across the today column, with an 8×8 dot on the left and a small time pill (`HH:MM`) on the right with `bg: var(--bg)` for clean cutout
### 3.6 Events (`ev`)
Two visual modes:
**Timed event card** — coloured background by source, 2px left-border accent, `border-radius: 4px`, `padding: 8px 10px`. Title row has an inline 16px source icon (`.t .src`) before the text. Synced events show a small sync arrow at top-right (`.sync`, opacity .55). Meta line: `HH:MM HH:MM · Location`.
**Note event** (`.ev.note`) — for todos placed at a time but without a duration block: transparent background, no border-left, no rounding, just small inline icon + text. Used for github-source items, key/cake/car category notes, etc.
Source icon mapping (16px, currentColor SVG):
- `git` — github / dev tasks
- `money` — finance / banking
- `book` — Legasthenie / Nachhilfe
- `home` — Garde / home
- `key`, `cake`, `car` — category notes
- `bolt` — focus / quick task
- `pin`, `info` — generic
### 3.7 Anyday tasks (`tasks`)
- Section background: `--tasks-bg` (`#f4f2ee` warm light grey)
- 6-up grid of white list cards (`--paper`, 1px `--line`, 8px radius, `min-height: 200px`, padding `12px 12px 8px`)
- Card heading: 6px colored dot · uppercase project name · count chip on right
- Each item: 9×9 unchecked box (1px `--ink-5`) · text · hairline `--line-soft` underneath
- **Tabs above the grid** are a segmented-pill control:
- Container: `inline-flex`, `padding: 4px`, `bg: rgba(0,0,0,.05)`, `border-radius: 10px`
- Active tab: white pill, weight 600, shadow `0 1px 2px rgba(0,0,0,.06), 0 0 0 1px rgba(0,0,0,.04)`
- Inactive: text `--ink-3`, hover white-50 background
- Each tab has a count chip — small grey rounded-rect, tabular-nums
### 3.8 Right settings drawer (`rside`)
- 360px white panel, slides in from right edge
- Vertical icon-tab strip across the top (settings, globe, link, sync, box, user, palette, spark)
- Each row: small label (11px `--ink-3`, weight 500) + input/checkbox/segmented control
- Inputs: 1px `--line`, 6px radius, 7px 10px padding, 13px `--ink-2`
- Help text under fields: 10px `--ink-4`
- Sections separated by full-bleed `--line` `<hr>`
- Close `×` top-right; clicking the gear icon in the left rail or the edge tab toggles it
### 3.9 Floating corner buttons
- 28×28, 6px radius, `top: 8px`
- Left: sidebar-toggle. Right: search.
- These sit **on top of** the header bar.
---
## 4 · Iconography
All icons are **16px, currentColor, 1.4 stroke-width SVG**, drawn on a 20×20 viewBox, no fill (line-art). Examples: cal, list, kanban, target, grid, clock, search, plus, proj, repeat, bolt, moon, sun, setg, sync, print, user, download, sidebar, chevL/R, eye, undo, redo, refresh, sliders, globe, link, box, palette, spark, info, money, git, book, home, car, key, cake.
Hover icons go from `--ink-3``--ink`. Active state for view/filter icons goes from `--ink-3``--accent` on `--accent-soft` background.
---
## 5 · What to keep from the existing app
- All data shapes, server actions, API routes
- `next-auth` wiring, middleware
- Drag-and-drop calendar event editing
- Recurring task logic
- Calendar-sync flows (Google etc.)
- Keyboard shortcuts
- Print view (we can restyle it, but keep the print route)
---
## 6 · Suggested PR breakdown
1. **`feat(design): add tokens`** — CSS variables / tailwind extend, no markup changes
2. **`feat(design): app shell + header + corner buttons`** — top-level layout
3. **`feat(design): left rail`** — collapse old toolbar into rail with flyouts
4. **`feat(design): right settings drawer`** — slide-in panel
5. **`feat(design): day grid`** — hairline rules, no verticals, today wash, day headers
6. **`feat(design): all-day strip`** — solid chip pills
7. **`feat(design): events`** — source-coloured cards + note variant + source icons
8. **`feat(design): anyday tasks`** — segmented tabs + 6-up white cards on warm grey
9. **`chore(design): typography pass`** — Geist + Fraunces, sizes from §1
10. **`chore(design): icon set swap`** — 16px line-art set
Each PR small enough to eyeball. After step 9 the app should look like the mock; step 10 is polish.
---
## 7 · Open questions for the engineer
- Is Geist already loaded? If not, add it via `next/font` (don't pull from Google Fonts at runtime).
- Does the existing `theme` system support multiple accent colors per calendar source, or is that a new concept? If new, hold off on the per-source event colors and ship the today-red + neutral-event scheme first.
- Confirm dark mode is in scope — the mock is light-only; tokens for dark would need to be derived in a follow-up.
---
*End of notes. When in doubt, open `Todo Calendar Redesign v2.html` in a browser and copy what you see.*

View File

@ -1,214 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const dailyTasks = [
"Pretend to listen to partner's work drama",
"Feed the kids (again? really?)",
"Hide Amazon packages from spouse",
"Attempt to fold a fitted sheet, give up and roll it into a ball",
"Cook a meal that exactly 0% of the family will appreciate",
"Look at the mess. Sigh. Walk away.",
"Move the laundry from washer to dryer, leave it there for 3 days",
"Find out what died in the fridge",
"Pay bills and cry softly",
"Unload dishwasher with immense resentment",
"Scroll on phone until legs go numb on the toilet",
"Perform archeological dig in the teenage bedroom",
"Interrogate child about missing tupperware lid",
"Nod enthusiastically at toddler's incomprehensible story",
"Try to decipher spouse's 'helpful' grocery list",
"Pretend I don't see the full trash can",
"Vacuum the rug and ignore the corners",
"Wonder where all my money went"
];
const workEvents = [
"Meaningless sync meeting #42",
"Stare blankly at spreadsheet",
"Listen to boss talk about synergy",
"Reply 'As per my previous email...' to Gary",
"Pretend to be busy so no one asks me for help",
"'Quick chat' that ruins my entire afternoon",
"Update Jira tickets to make it look like I did something",
"Mute mic and eat aggressively loud chips during all-hands",
"Consider moving to the woods and becoming a hermit",
"Frantically search for the tab that is playing music",
"Draft angry email, delete it, send 'Sounds good!'",
"Nod meaningfully during presentation I don't understand"
];
const birthdayTasks = [
"Buy a gift that makes me look thoughtful but was actually on sale",
"Wrap the present (using newspaper because I forgot wrapping paper)",
"Attend birthday party and strategically position myself near the snack table",
"Fake a smile while listening to Uncle Bob's views",
"Smuggle leftover cake home in napkins"
];
async function main() {
const user = await prisma.user.findUnique({
where: { email: 'kugelblitz@gmx.de' }
});
if (!user) return;
// 1. Delete all recently generated tasks
await prisma.task.deleteMany({
where: {
userId: user.id,
createdAt: { gte: new Date(new Date().setHours(0,0,0,0)) },
title: { not: 'Meeting with team' } // leave the one subagent added if you want, or just let it delete if the title is strictly dailyTasks. Actually just delete all created today!
}
});
// 2. Create Projects for beautiful colors
let pWork = await prisma.project.findFirst({ where: { userId: user.id, name: 'Work' } });
if (!pWork) pWork = await prisma.project.create({ data: { userId: user.id, name: 'Work', color: '#3b82f6', icon: 'faBriefcase' } });
let pFamily = await prisma.project.findFirst({ where: { userId: user.id, name: 'Family' } });
if (!pFamily) pFamily = await prisma.project.create({ data: { userId: user.id, name: 'Family', color: '#10b981', icon: 'faHouse' } });
let pLife = await prisma.project.findFirst({ where: { userId: user.id, name: 'Life' } });
if (!pLife) pLife = await prisma.project.create({ data: { userId: user.id, name: 'Life', color: '#f59e0b', icon: 'faHeart' } });
// 3. Find target Someday lists to populate the bottom
const lists = await prisma.somedayList.findMany({ where: { userId: user.id } });
const kugelList = lists.find(l => l.title.includes('kugel'));
const papaList = lists.find(l => l.title.includes('Papa'));
// Target dates: March 16 to April 12, 2026
const startDate = new Date('2026-03-16T00:00:00Z');
const endDate = new Date('2026-04-12T00:00:00Z');
const birthdayDate = new Date('2026-04-04T00:00:00Z');
const aprilFools = new Date('2026-04-01T00:00:00Z');
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const isBirthday = d.getTime() === birthdayDate.getTime();
const isAprilFools = d.getTime() === aprilFools.getTime();
const dateStr = d.toISOString().split('T')[0];
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
// We want a DENSE calendar for the tutorial. 50% fill rate for EACH SLOT.
// Daily Life events (scheduled on grid)
const numLife = Math.floor(Math.random() * 2) + 1;
for (let i = 0; i < numLife; i++) {
const h = Math.floor(Math.random() * 3) + 6; // 6-8 AM or evening
await prisma.task.create({
data: {
title: dailyTasks[Math.floor(Math.random() * dailyTasks.length)],
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: `${h.toString().padStart(2, '0')}:00`,
endTime: `${(h + 1).toString().padStart(2, '0')}:00`,
projectId: pLife.id
}
});
}
if (!isWeekend) {
// Work events
let currentHour = 8;
while (currentHour <= 16) {
if (Math.random() > 0.4) { // 60% chance to put a work meeting in this block
const duration = Math.random() > 0.5 ? 1 : 2;
await prisma.task.create({
data: {
title: workEvents[Math.floor(Math.random() * workEvents.length)],
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: `${currentHour.toString().padStart(2, '0')}:00`,
endTime: `${(currentHour + duration).toString().padStart(2, '0')}:00`,
projectId: pWork.id
}
});
currentHour += duration + 1; // leave at least 1h gap
} else {
currentHour += 1;
}
}
} else {
// Weekend Family events
const numFam = Math.floor(Math.random() * 3) + 2;
let famHour = 9;
for (let i = 0; i < numFam; i++) {
await prisma.task.create({
data: {
title: dailyTasks[Math.floor(Math.random() * dailyTasks.length)],
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: `${famHour.toString().padStart(2, '0')}:00`,
endTime: `${(famHour + 1).toString().padStart(2, '0')}:00`,
projectId: pFamily.id
}
});
famHour += 2;
}
}
// Add April Fools extra
if (isAprilFools) {
await prisma.task.create({
data: {
title: "Attempt a prank, fail miserably, apologize to HR",
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: "10:30",
endTime: "11:30",
projectId: pWork.id
}
});
}
// Add Birthday extras
if (isBirthday) {
for (let i = 0; i < birthdayTasks.length; i++) {
const time = 14 + i;
await prisma.task.create({
data: {
title: birthdayTasks[i],
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: `${time}:00`,
endTime: `${time + 1}:00`,
projectId: pFamily.id
}
});
}
}
}
// Populate SomeDay Lists
if (kugelList) {
for(let i=0; i<3; i++) {
await prisma.task.create({
data: {
title: dailyTasks[Math.floor(Math.random() * dailyTasks.length)],
userId: user.id,
somedayListId: kugelList.id,
order: i
}
});
}
}
if (papaList) {
for(let i=0; i<3; i++) {
await prisma.task.create({
data: {
title: workEvents[Math.floor(Math.random() * workEvents.length)],
userId: user.id,
somedayListId: papaList.id,
order: i
}
});
}
}
console.log("Densley populated the calendar with wonderful colored events!");
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,153 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const dailyTasks = [
"Pretend to listen to partner's work drama",
"Feed the kids (again? really?)",
"Hide Amazon packages from spouse",
"Attempt to fold a fitted sheet, give up and roll it into a ball",
"Cook a meal that exactly 0% of the family will appreciate",
"Look at the mess. Sigh. Walk away.",
"Move the laundry from washer to dryer, leave it there for 3 days",
"Find out what died in the fridge",
"Pay bills and cry softly",
"Unload dishwasher with immense resentment",
"Scroll on phone until legs go numb on the toilet",
"Perform archeological dig in the teenage bedroom",
"Interrogate child about missing tupperware lid",
"Nod enthusiastically at toddler's incomprehensible story",
"Try to decipher spouse's 'helpful' grocery list",
"Pretend I don't see the full trash can"
];
const workEvents = [
"Meaningless sync meeting #42",
"Stare blankly at spreadsheet",
"Listen to boss talk about synergy",
"Reply 'As per my previous email...' to Gary",
"Pretend to be busy so no one asks me for help",
"'Quick chat' that ruins my entire afternoon",
"Update Jira tickets to make it look like I did something",
"Mute mic and eat aggressively loud chips during all-hands",
"Consider moving to the woods and becoming a hermit",
"Frantically search for the tab that is playing music"
];
const birthdayTasks = [
"Buy a gift that makes me look thoughtful but was actually on sale",
"Wrap the present (using newspaper because I forgot wrapping paper)",
"Attend birthday party and strategically position myself near the snack table",
"Fake a smile while listening to Uncle Bob's views",
"Smuggle leftover cake home in napkins"
];
async function main() {
const user = await prisma.user.findUnique({
where: { email: 'kugelblitz@gmx.de' }
});
if (!user) {
console.log("User kugelblitz@gmx.de not found in database.");
return;
}
console.log(`Using user: ${user.email} (${user.id})`);
// Target dates: March 16 to April 12, 2026
const startDate = new Date('2026-03-16T00:00:00Z');
const endDate = new Date('2026-04-12T00:00:00Z');
// Birthday party around April 4th
const birthdayDate = new Date('2026-04-04T00:00:00Z');
// April Fools on April 1st
const aprilFools = new Date('2026-04-01T00:00:00Z');
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
// ~40% fill rate for normal days
const isBirthday = d.getTime() === birthdayDate.getTime();
const isAprilFools = d.getTime() === aprilFools.getTime();
// Skip ~60% of the regular days to keep density 30-50%
if (!isBirthday && !isAprilFools && Math.random() > 0.45) {
continue;
}
const dateStr = d.toISOString().split('T')[0];
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
// Add 1-2 daily tasks (without specific time)
const numTasks = Math.floor(Math.random() * 2) + 1;
for (let i = 0; i < numTasks; i++) {
const taskName = dailyTasks[Math.floor(Math.random() * dailyTasks.length)];
await prisma.task.create({
data: {
title: taskName,
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
order: i,
}
});
}
// Add 1-2 work events (with time) if it's a weekday
if (!isWeekend) {
if (Math.random() > 0.3) {
const numEvents = Math.floor(Math.random() * 2) + 1;
let currentHour = Math.floor(Math.random() * (11 - 8 + 1)) + 8; // Morning 8-11
for (let e = 0; e < numEvents; e++) {
const eventName = workEvents[Math.floor(Math.random() * workEvents.length)];
const startTime = `${currentHour.toString().padStart(2, '0')}:00`;
await prisma.task.create({
data: {
title: eventName,
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: startTime,
endTime: `${(currentHour+1).toString().padStart(2, '0')}:00`,
}
});
currentHour += Math.floor(Math.random() * 3) + 2; // Jump 2-4 hours for next event
if (currentHour > 17) break;
}
}
}
// Add April Fools extra
if (isAprilFools) {
await prisma.task.create({
data: {
title: "Attempt a prank, fail miserably, apologize to HR",
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: "10:30",
endTime: "11:00",
}
});
}
// Add Birthday extras
if (isBirthday) {
for (let i = 0; i < birthdayTasks.length; i++) {
const time = i >= 2 ? `${14 + i}:00` : undefined; // Party elements have times 16:00, 17:00, 18:00
await prisma.task.create({
data: {
title: birthdayTasks[i],
userId: user.id,
scheduledDate: new Date(dateStr),
dayOfWeek: d.getDay(),
startTime: time,
endTime: time ? `${15 + i}:00` : undefined,
}
});
}
}
}
console.log("Successfully seeded the calendar with fun, sarcastic data!");
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,118 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const eventNames = [
"Dentist Appointment",
"Coffee with Sarah",
"Quarterly Review",
"Car Inspection",
"Therapy Session",
"Lunch with Bob"
];
const allDayEvents = [
"Spring Festival",
"Bank Holiday",
"Company Offsite",
"Project Deadline"
];
function getWeekStart(date) {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
const day = d.getUTCDay();
const diff = d.getUTCDate() - day + (day === 0 ? -6 : 1); // Monday is 1
d.setUTCDate(diff);
return d;
}
async function main() {
const user = await prisma.user.findUnique({
where: { email: 'kugelblitz@gmx.de' }
});
if (!user) return;
// 1. Ensure a dummy calendar connection exists
let connection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'google' }
});
if (!connection) {
connection = await prisma.calendarConnection.create({
data: {
userId: user.id,
provider: 'google',
accessToken: 'dummy-token',
calendars: JSON.stringify([{ id: 'primary', name: 'My Calendar', color: '#4285F4' }])
}
});
}
// 2. Clear old generated mock events
const mockTitles = [...eventNames, ...allDayEvents];
await prisma.cachedCalendarEvent.deleteMany({
where: {
connectionId: connection.id,
title: { in: mockTitles }
}
});
const startDate = new Date('2026-03-16T00:00:00Z');
const endDate = new Date('2026-04-12T00:00:00Z');
let idCounter = 1;
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
console.log('Processing date:', d.toISOString());
const dateStr = d.toISOString().split('T')[0];
const weekStart = getWeekStart(d);
if (Math.random() > 0.6) {
// Add All-Day Event
await prisma.cachedCalendarEvent.create({
data: {
userId: user.id,
externalId: `mock-allday-${idCounter++}`,
connectionId: connection.id,
provider: 'google',
calendarId: 'primary',
calendarTitle: 'My Calendar',
calendarColor: '#EA4335',
title: allDayEvents[Math.floor(Math.random() * allDayEvents.length)],
startDate: dateStr,
endDate: dateStr,
weekStart: weekStart
}
});
}
if (Math.random() > 0.5) {
// Add Timed Event
const hour = Math.floor(Math.random() * 6) + 10; // 10 to 15
const startDt = new Date(d);
startDt.setUTCHours(hour, 0, 0, 0);
const endDt = new Date(d);
endDt.setUTCHours(hour + 1, 0, 0, 0);
await prisma.cachedCalendarEvent.create({
data: {
userId: user.id,
externalId: `mock-timed-${idCounter++}`,
connectionId: connection.id,
provider: 'google',
calendarId: 'primary',
calendarTitle: 'My Calendar',
calendarColor: '#4285F4',
title: eventNames[Math.floor(Math.random() * eventNames.length)],
startDateTime: startDt,
endDateTime: endDt,
weekStart: weekStart
}
});
}
}
console.log("Successfully added simulated external calendar events!");
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,116 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const eventNames = [
"Dentist Appointment",
"Coffee with Sarah",
"Quarterly Review",
"Car Inspection",
"Therapy Session",
"Lunch with Bob"
];
const allDayEvents = [
"Spring Festival",
"Bank Holiday",
"Company Offsite",
"Project Deadline"
];
function getWeekStart(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday is 1
return new Date(d.setDate(diff));
}
async function main() {
const user = await prisma.user.findUnique({
where: { email: 'kugelblitz@gmx.de' }
});
if (!user) return;
// 1. Ensure a dummy calendar connection exists
let connection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'google' }
});
if (!connection) {
connection = await prisma.calendarConnection.create({
data: {
userId: user.id,
provider: 'google',
accessToken: 'dummy-token',
calendars: JSON.stringify([{ id: 'primary', name: 'My Calendar', color: '#4285F4' }])
}
});
}
// 2. Clear old generated mock events
const mockTitles = [...eventNames, ...allDayEvents];
await prisma.cachedCalendarEvent.deleteMany({
where: {
connectionId: connection.id,
title: { in: mockTitles }
}
});
const startDate = new Date('2026-03-16T00:00:00Z');
const endDate = new Date('2026-04-12T00:00:00Z');
let idCounter = 1;
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const weekStart = getWeekStart(d);
if (Math.random() > 0.6) {
// Add All-Day Event
await prisma.cachedCalendarEvent.create({
data: {
userId: user.id,
externalId: `mock-allday-${idCounter++}`,
connectionId: connection.id,
provider: 'google',
calendarId: 'primary',
calendarTitle: 'My Calendar',
calendarColor: '#primary', // Or any string, frontend handles #
title: allDayEvents[Math.floor(Math.random() * allDayEvents.length)],
startDate: dateStr,
endDate: dateStr,
weekStart: weekStart
}
});
}
if (Math.random() > 0.5) {
// Add Timed Event
const hour = Math.floor(Math.random() * 6) + 10; // 10 to 15
const startDt = new Date(d);
startDt.setUTCHours(hour, 0, 0, 0);
const endDt = new Date(d);
endDt.setUTCHours(hour + 1, 0, 0, 0);
await prisma.cachedCalendarEvent.create({
data: {
userId: user.id,
externalId: `mock-timed-${idCounter++}`,
connectionId: connection.id,
provider: 'google',
calendarId: 'primary',
calendarTitle: 'My Calendar',
calendarColor: '#EA4335',
title: eventNames[Math.floor(Math.random() * eventNames.length)],
startDateTime: startDt,
endDateTime: endDt,
weekStart: weekStart
}
});
}
}
console.log("Successfully added simulated external calendar events!");
}
main().catch(console.error).finally(() => prisma.$disconnect());

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "my-weekly-todo-list",
"version": "1.104.1",
"version": "1.92.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-weekly-todo-list",
"version": "1.104.1",
"version": "1.92.0",
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.113.1",
"version": "1.99.2",
"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

@ -1,2 +0,0 @@
-- CachedCalendarEvent: store busy/free/tentative/oof status for incoming Outlook/Google sync
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "busyStatus" TEXT;

View File

@ -1,2 +0,0 @@
-- SomedayList: Microsoft Graph delta token for cheap incremental MS To-Do pulls
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "syncDeltaToken" TEXT;

View File

@ -1,3 +0,0 @@
-- User: per-user CSS variable overrides and free-form custom stylesheet (Styling settings tab)
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "customCssVars" JSONB;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "customCss" TEXT;

View File

@ -116,8 +116,6 @@ model User {
weatherLon Float?
viewSettings Json?
weatherRecentCities Json?
customCssVars Json?
customCss String? @db.Text
hasCompletedOnboarding Boolean @default(true)
showCalendarProviderIcon Boolean @default(false)
headerCurrentDayFormat String? @default("DDD, DD. MMMM YYYY")
@ -237,7 +235,6 @@ model SomedayList {
externalId String?
externalProvider String?
lastSyncedAt DateTime?
syncDeltaToken String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@ -282,7 +279,6 @@ model CachedCalendarEvent {
recurringEventId String?
isRecurring Boolean @default(false)
reminders Json?
busyStatus String?
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -20,7 +20,6 @@ export async function POST(request: NextRequest) {
id: true,
emailVerified: true,
emailVerificationExpires: true,
language: true,
},
});
@ -58,7 +57,7 @@ export async function POST(request: NextRequest) {
});
try {
await sendVerificationEmail(email, code, token, user.language);
await sendVerificationEmail(email, code, token);
} catch (emailError) {
console.error('Failed to resend verification email:', emailError);
return NextResponse.json(

View File

@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
},
});
await sendPasswordResetEmail(email, resetToken, (user as any).language);
await sendPasswordResetEmail(email, resetToken);
return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' });
}

View File

@ -1,18 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcryptjs';
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
const prisma = new PrismaClient();
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, email, password } = body;
// Prefer the explicit language from the form (filled in by the signup
// page using navigator.language) and fall back to Accept-Language.
const language = body.language
|| languageFromAcceptLanguage(request.headers.get('accept-language'));
const { name, email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
@ -46,12 +42,11 @@ export async function POST(request: NextRequest) {
emailVerificationCode: code,
emailVerificationToken: token,
emailVerificationExpires: expires,
language,
},
});
try {
await sendVerificationEmail(email, code, token, language);
await sendVerificationEmail(email, code, token);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
}
@ -82,7 +77,6 @@ export async function POST(request: NextRequest) {
name: name || undefined,
email,
passwordHash,
language,
emailVerificationCode: code,
emailVerificationToken: token,
emailVerificationExpires: expires,
@ -98,7 +92,7 @@ export async function POST(request: NextRequest) {
// Send verification email
try {
await sendVerificationEmail(email, code, token, language);
await sendVerificationEmail(email, code, token);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
// User is created but email failed — they can use "resend" later

View File

@ -78,58 +78,31 @@ export async function POST(request: NextRequest) {
}
}
// GET: Legacy one-click link.
//
// This used to verify-and-redirect on a single GET, but corporate inbox
// scanners (M365 Safe Links, etc.) pre-fetch URLs to scan them — which
// silently consumed the one-shot token before the recipient ever saw the
// email (the bug behind point #2). New emails point straight at the client
// page; this handler only redirects there for backward compatibility with
// emails already in transit.
// GET: Verify via one-click link
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token');
const target = new URL('/auth/verify-email', request.url);
if (token) target.searchParams.set('token', token);
return NextResponse.redirect(target);
}
// PATCH: Verify via token from the email link (called by the client page after
// the user actually clicks the button — survives scanner pre-fetches).
export async function PATCH(request: NextRequest) {
try {
const { token } = await request.json();
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
return NextResponse.redirect(
new URL('/auth/verify-email?error=missing_token', request.url)
);
}
const user = await prisma.user.findFirst({
where: {
emailVerificationToken: token,
},
select: {
id: true,
email: true,
emailVerified: true,
emailVerificationExpires: true,
emailVerificationExpires: { gt: new Date() },
},
});
// Friendly path: token already consumed but the email is verified — treat as success.
if (!user) {
return NextResponse.json({ error: 'Invalid token. It may have already been used.' }, { status: 400 });
}
if (user.emailVerified) {
return NextResponse.json({ message: 'Email already verified', email: user.email });
}
if (!user.emailVerificationExpires || new Date() > user.emailVerificationExpires) {
return NextResponse.json(
{ error: 'Verification link expired. Please request a new code.', email: user.email },
{ status: 400 }
return NextResponse.redirect(
new URL('/auth/verify-email?error=invalid_token', request.url)
);
}
// Verify the user
await prisma.user.update({
where: { id: user.id },
data: {
@ -141,9 +114,13 @@ export async function PATCH(request: NextRequest) {
},
});
return NextResponse.json({ message: 'Email verified successfully', email: user.email });
return NextResponse.redirect(
new URL('/auth/login?verified=true', request.url)
);
} catch (error) {
console.error('Token verification error:', error);
return NextResponse.json({ error: 'Failed to verify email' }, { status: 500 });
return NextResponse.redirect(
new URL('/auth/verify-email?error=server_error', request.url)
);
}
}

View File

@ -3,7 +3,9 @@ import { validateCredentials } from '@/lib/apple-calendar';
import { CalendarConnection } from '@/lib/calendar-sync';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: Request) {
try {

View File

@ -2,10 +2,12 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { google } from 'googleapis';
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
// Google Calendar OAuth callback endpoint
export async function GET(request: NextRequest) {
try {

View File

@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
import { getUserDatabases } from '@/lib/notion-calendar';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);

View File

@ -2,7 +2,9 @@ import { NextResponse } from 'next/server';
import { validateCredentials } from '@/lib/synology-calendar';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: Request) {
try {

View File

@ -127,16 +127,7 @@ export async function GET(req: Request) {
};
// Try each user-configured URL first
const PRIVATE_IP = /^(localhost|127\.|0\.0\.0\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|::1$|fc00:|fe80:)/i;
function isSafeQuoteUrl(raw: string): boolean {
try {
const u = new URL(raw);
if (!['http:', 'https:'].includes(u.protocol)) return false;
if (PRIVATE_IP.test(u.hostname)) return false;
return true;
} catch { return false; }
}
const userUrls: string[] = ((user as any)?.quoteSourceUrls?.filter(Boolean) || []).filter(isSafeQuoteUrl);
const userUrls: string[] = (user as any)?.quoteSourceUrls?.filter(Boolean) || [];
for (const url of userUrls) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });

View File

@ -2,13 +2,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, createGoogleTaskList } from '@/lib/google-tasks';
import { createMsTodoList } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
import { createAppleReminderList } from '@/lib/apple-reminders';
import { createSynologyReminderList } from '@/lib/synology-tasks';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);

View File

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
import { notifyUser } from '@/lib/sse';
const prisma = new PrismaClient();
async function resolveUserId(email: string): Promise<string | null> {
const user = await prisma.user.findUnique({
where: { email },
@ -121,8 +123,6 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get('id');
// tasksOnly=true: soft-disconnect (keep list record with tab, just remove tasks + external link)
const tasksOnly = searchParams.get('tasksOnly') === 'true';
// deleteExternal=true: also remove the list from the connected provider (Outlook/Google)
const deleteExternal = searchParams.get('deleteExternal') === 'true';
if (!id) {
return NextResponse.json(
@ -143,33 +143,6 @@ export async function DELETE(request: NextRequest) {
);
}
// If asked to remove the list from the external provider too, do so first
// (so a Graph/API failure doesn't orphan the local state).
let externalDeleteFailed = false;
if (deleteExternal && list.externalId && list.externalProvider) {
try {
if (list.externalProvider === 'outlook') {
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
const { deleteMsTodoList } = await import('@/lib/microsoft-todo');
const token = await getOutlookAccessToken(userId);
if (token) await deleteMsTodoList(token, list.externalId);
} else if (list.externalProvider === 'google') {
const { createGoogleClient, deleteGoogleTaskList } = await import('@/lib/google-tasks');
const account = await prisma.account.findFirst({
where: { userId, provider: { in: ['google-calendar', 'google'] } }
});
if (account?.access_token) {
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
await deleteGoogleTaskList(client, list.externalId);
}
}
// Note: Synology task list deletion not yet implemented at the provider level.
} catch (extErr) {
console.error('Failed to delete external list, proceeding with local cleanup:', extErr);
externalDeleteFailed = true;
}
}
// Soft-delete tasks in this list (they can be recovered from trash)
await prisma.task.updateMany({
where: { somedayListId: id },
@ -191,7 +164,7 @@ export async function DELETE(request: NextRequest) {
});
notifyUser(userId, "list-changed", { action: "deleted" });
return NextResponse.json({ success: true, externalDeleteFailed });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting someday list:', error);
return NextResponse.json(

View File

@ -2,11 +2,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted, fetchMsChecklistItems } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient();
interface SourceList {
id: string;
title: string;
@ -21,7 +23,6 @@ interface ImportedTask {
status: string;
sourceListTitle: string;
parentExternalId?: string;
important?: boolean;
}
export async function POST(req: NextRequest) {
@ -127,7 +128,6 @@ export async function POST(req: NextRequest) {
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
sourceListTitle: sourceList.title,
important: t.importance === 'high',
});
// Fetch checklist items as sub-tasks
@ -295,7 +295,6 @@ export async function POST(req: NextRequest) {
somedayListId: somedayList.id,
lastSyncedAt: new Date(),
deletedAt: null, // clear soft-delete from a previous disconnect
...(task.important !== undefined ? { importance: task.important ? true : existingTask.importance } : {}),
}
});
externalToLocalId.set(task.externalId, existingTask.id);
@ -314,8 +313,7 @@ export async function POST(req: NextRequest) {
externalId: task.externalId,
externalProvider: provider,
externalListId: task.externalListId,
lastSyncedAt: new Date(),
...(task.important ? { importance: true } : {}),
lastSyncedAt: new Date()
}
});
externalToLocalId.set(task.externalId, newTask.id);

View File

@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
import { fetchMsTodoLists } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);

View File

@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { Task } from '@prisma/client';
import { prisma } from '@/lib/prisma';
import { PrismaClient, Task } from '@prisma/client';
import { notifyUser } from '@/lib/sse';
const prisma = new PrismaClient();
// Validate and sanitize a URL — only allow http/https, reject javascript: and data: schemes
function sanitizeUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
@ -24,71 +25,6 @@ const generateVirtualId = (originalId: string, dateStr: string) => {
return `virtual-${originalId}-${dateStr}`;
};
/**
* Push a single task field-update to its external provider (Outlook / Google / Synology).
* Best-effort: fire-and-forget from the caller, which logs errors.
*/
async function pushTaskToExternal(
task: Task,
fields: { title?: string; notes?: string; completed?: boolean; scheduledDate?: string | null; importance?: boolean | null }
): Promise<void> {
if (!task.externalProvider || !task.externalId || !task.externalListId) return;
if (task.externalProvider === 'outlook') {
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
const { updateMsTodoTask } = await import('@/lib/microsoft-todo');
const token = await getOutlookAccessToken(task.userId);
if (!token) return;
const updates: any = {};
if (fields.title !== undefined) updates.title = fields.title;
if (fields.notes !== undefined) updates.body = fields.notes ?? '';
if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'notStarted';
if (fields.scheduledDate !== undefined) {
updates.dueDateTime = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
}
if (fields.importance !== undefined) updates.importance = fields.importance ? 'high' : 'normal';
if (Object.keys(updates).length > 0) {
await updateMsTodoTask(token, task.externalListId, task.externalId, updates);
}
} else if (task.externalProvider === 'google') {
const { createGoogleClient, updateGoogleTask } = await import('@/lib/google-tasks');
const account = await prisma.account.findFirst({
where: { userId: task.userId, provider: { in: ['google-calendar', 'google'] } }
});
if (!account?.access_token) return;
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
const updates: any = {};
if (fields.title !== undefined) updates.title = fields.title;
if (fields.notes !== undefined) updates.notes = fields.notes ?? '';
if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'needsAction';
if (fields.scheduledDate !== undefined) {
updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
}
// Google Tasks API has no native importance/star — silently ignored.
if (Object.keys(updates).length > 0) {
await updateGoogleTask(client, task.externalListId, task.externalId, updates);
}
} else if (task.externalProvider === 'synology') {
const { updateSynologyTask } = await import('@/lib/synology-tasks');
const conn = await prisma.calendarConnection.findFirst({
where: { userId: task.userId, provider: 'synology' }
});
if (!conn?.accessToken || !conn?.refreshToken) return;
const [user, pw] = conn.accessToken.split(':');
if (!user || !pw) return;
const updates: any = {};
if (fields.title !== undefined) updates.title = fields.title;
if (fields.notes !== undefined) updates.notes = fields.notes ?? '';
if (fields.completed !== undefined) updates.completed = fields.completed;
if (fields.scheduledDate !== undefined) {
updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
}
if (Object.keys(updates).length > 0) {
await updateSynologyTask(conn.refreshToken, user, pw, task.externalListId, task.externalId, updates);
}
}
}
// Max virtual instances generated per recurring series, keyed by recurrence unit.
// Caps pathological cases (e.g. a daily task with 90-day horizon = 90 instances).
const MAX_INSTANCES_PER_SERIES: Record<string, number> = {
@ -373,12 +309,7 @@ export async function POST(request: NextRequest) {
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
const accessToken = await getOutlookAccessToken(userId);
if (accessToken) {
const msTask = await createMsTodoTask(accessToken, somedayList.externalId, {
title,
body: description || undefined,
dueDateTime: scheduledDate || undefined,
importance: importance ? 'high' : undefined,
});
const msTask = await createMsTodoTask(accessToken, somedayList.externalId, { title });
externalId = msTask.id;
externalProvider = 'outlook';
externalListId = somedayList.externalId;
@ -579,21 +510,6 @@ export async function PATCH(request: NextRequest) {
},
});
// Push changes to external provider (fire-and-forget) when this task is linked
if (task.externalProvider && task.externalId && task.externalListId) {
const pushFields: Record<string, any> = {};
if (title !== undefined) pushFields.title = title;
if (description !== undefined) pushFields.notes = description;
if (completed !== undefined) pushFields.completed = completed;
if (scheduledDate !== undefined) pushFields.scheduledDate = scheduledDate;
if (importance !== undefined) pushFields.importance = importance;
if (Object.keys(pushFields).length > 0) {
pushTaskToExternal(task, pushFields).catch(e =>
console.error('[TASK-SYNC] external push failed:', e)
);
}
}
// NOTE: We REMOVED the "create next task on completion" logic block here.
// Why? Because the projection system handles "next tasks" automatically.
// If we kept it, completing a task would create a duplicate materialized task for the next date,

View File

@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { createGoogleClient, createGoogleTask, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks';
import { fetchMsTodoTasksForSync, fetchMsTodoTasksDelta, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
// GET - Pull changes from Google Tasks into local DB
@ -144,8 +144,11 @@ export async function GET(req: NextRequest) {
continue;
}
// Field-by-field reconciliation (see Outlook block for rationale).
const updateData: any = {};
const remoteUpdated = new Date(remote.updated);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) continue;
const updateData: any = { lastSyncedAt: new Date() };
const remoteCompleted = remote.status === 'completed';
if (remoteCompleted !== localTask.completed) {
@ -170,13 +173,17 @@ export async function GET(req: NextRequest) {
updateData.parentTaskId = null;
}
if (Object.keys(updateData).length > 0) {
updateData.lastSyncedAt = new Date();
if (Object.keys(updateData).length > 1) {
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
}
}
@ -268,110 +275,31 @@ export async function GET(req: NextRequest) {
outlookByList.get(task.externalListId)!.push(task);
}
// Pre-load delta tokens for synced lists so we can do incremental pulls
const syncedListIdToRow = new Map<string, { id: string; title: string; syncDeltaToken: string | null }>();
for (const sl of outlookSyncedLists) {
if (sl.externalId) {
syncedListIdToRow.set(sl.externalId, {
id: sl.id,
title: sl.title,
syncDeltaToken: (sl as any).syncDeltaToken ?? null,
});
}
}
for (const listId of outlookListIds) {
const localTasks = outlookByList.get(listId) || [];
try {
const syncedListRow = syncedListIdToRow.get(listId);
const previousDeltaToken = syncedListRow?.syncDeltaToken ?? null;
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
// Try delta-first when we have a stored token and a linked someday list
// (delta only makes sense when we can also create local tasks for new
// remote ones — otherwise we risk losing creations on a stale chain).
let remoteTasks: any[];
let isDelta = false;
let newDeltaToken: string | null = null;
if (syncedListRow && previousDeltaToken) {
try {
const result = await fetchMsTodoTasksDelta(outlookToken, listId, previousDeltaToken);
remoteTasks = result.tasks;
newDeltaToken = result.deltaToken;
isDelta = true;
} catch (e: any) {
if (e?.code === 'DELTA_EXPIRED') {
console.log(`[SYNC] Delta token expired for ${listId}, falling back to full fetch`);
remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
// Establish a fresh delta chain on the next sync
const fresh = await fetchMsTodoTasksDelta(outlookToken, listId, null).catch(() => null);
newDeltaToken = fresh?.deltaToken ?? null;
} else {
throw e;
}
}
} else if (syncedListRow) {
// First time we sync this list — do a full fetch AND prime the delta chain
remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
const fresh = await fetchMsTodoTasksDelta(outlookToken, listId, null).catch(() => null);
newDeltaToken = fresh?.deltaToken ?? null;
} else {
// Not a synced list — just touching individual tasks; full fetch is fine
remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
}
const remoteMap = new Map(remoteTasks.map((t: any) => [t.id, t]));
const existingExternalIds = new Set(localTasks.map(t => t.externalId));
// -- Delta deletions: remote tasks marked _deleted are gone in Outlook --
if (isDelta) {
const deletedIds = remoteTasks.filter((r: any) => r._deleted).map((r: any) => r.id);
if (deletedIds.length > 0) {
const result = await prisma.task.updateMany({
where: {
userId: user.id,
externalProvider: 'outlook',
externalListId: listId,
externalId: { in: deletedIds },
deletedAt: null,
},
data: { deletedAt: new Date() },
});
deleted += result.count;
}
}
// For full fetches, detect locally-known tasks that vanished from Outlook
if (!isDelta) {
for (const localTask of localTasks) {
const remote = remoteMap.get(localTask.externalId!);
if (!remote) {
await prisma.task.update({
where: { id: localTask.id },
data: { deletedAt: new Date() }
});
deleted++;
}
}
continue;
}
// -- Reconcile updates --
// For delta: iterate the (small) returned list and reconcile each.
// For full: iterate local tasks (we already handled deletions above).
const localById = new Map(localTasks.map(t => [t.externalId!, t]));
const reconcileTargets = isDelta
? remoteTasks.filter((r: any) => !r._deleted)
: remoteTasks;
const remoteUpdated = new Date(remote.lastModifiedDateTime);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) continue;
for (const remote of reconcileTargets) {
const localTask = localById.get(remote.id);
if (!localTask) continue; // new task — handled below
// Field-by-field reconciliation: always check each field and update if it
// differs from remote. Local-side mutations are pushed eagerly via
// /api/tasks PATCH, so a remote-side change is the authoritative source
// when fields disagree at pull time.
const updateData: any = {};
const updateData: any = { lastSyncedAt: new Date() };
const remoteCompleted = isMsTodoTaskCompleted(remote.status);
if (remoteCompleted !== localTask.completed) {
@ -387,36 +315,24 @@ export async function GET(req: NextRequest) {
updateData.description = remoteNotes;
}
// Outlook To-Do star ⇄ local importance flag
const remoteImportant = remote.importance === 'high';
if (remoteImportant !== (localTask.importance === true)) {
updateData.importance = remoteImportant;
}
// Outlook dueDateTime ⇄ local scheduledDate
const remoteDue = remote.dueDateTime?.dateTime
? new Date(remote.dueDateTime.dateTime)
: null;
const localDue = localTask.scheduledDate ? new Date(localTask.scheduledDate) : null;
const remoteDueMs = remoteDue?.getTime() ?? null;
const localDueMs = localDue?.getTime() ?? null;
if (remoteDueMs !== localDueMs) {
updateData.scheduledDate = remoteDue;
}
if (Object.keys(updateData).length > 0) {
updateData.lastSyncedAt = new Date();
if (Object.keys(updateData).length > 1) {
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
}
}
// -- Create new local tasks for remote tasks we don't have yet --
if (syncedListRow) {
const newRemoteTasks = reconcileTargets.filter((rt: any) => !existingExternalIds.has(rt.id));
// Create new local tasks for remote tasks not yet in local DB
const somedayListInfo = outlookListIdToSomedayList.get(listId);
if (somedayListInfo) {
const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id));
for (const remote of newRemoteTasks) {
if (!remote.title || !remote.title.trim()) continue;
@ -427,11 +343,7 @@ export async function GET(req: NextRequest) {
title: remote.title,
description: remote.body?.content || null,
completed: isMsTodoTaskCompleted(remote.status),
importance: remote.importance === 'high' ? true : null,
scheduledDate: remote.dueDateTime?.dateTime
? new Date(remote.dueDateTime.dateTime)
: null,
somedayListId: syncedListRow.id,
somedayListId: somedayListInfo.id,
externalId: remote.id,
externalProvider: 'outlook',
externalListId: listId,
@ -442,14 +354,6 @@ export async function GET(req: NextRequest) {
created++;
}
}
// Persist the new delta token for next call (only when we got one)
if (syncedListRow && newDeltaToken) {
await prisma.somedayList.update({
where: { id: syncedListRow.id },
data: { syncDeltaToken: newDeltaToken },
});
}
} catch (listError) {
console.error(`Error syncing Outlook list ${listId}:`, listError);
}
@ -580,7 +484,7 @@ export async function PATCH(req: NextRequest) {
}
const body = await req.json();
const { taskId, completed, title, action, scheduledDate, notes, importance } = body;
const { taskId, completed, title, action, scheduledDate, notes } = body;
if (!taskId) {
return NextResponse.json({ error: 'Task ID required' }, { status: 400 });
@ -634,16 +538,13 @@ export async function PATCH(req: NextRequest) {
if (action === 'delete') {
await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId);
} else {
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null; importance?: 'low' | 'normal' | 'high' } = {};
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null } = {};
if (title !== undefined) updates.title = title;
if (notes !== undefined) updates.body = notes;
if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted';
if (scheduledDate !== undefined) {
updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null;
}
if (importance !== undefined) {
updates.importance = importance ? 'high' : 'normal';
}
if (Object.keys(updates).length > 0) {
await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates);
}
@ -757,7 +658,6 @@ export async function POST(req: NextRequest) {
title: task.title,
body: task.description || undefined,
dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
importance: task.importance ? 'high' : undefined,
});
const updatedTask = await prisma.task.update({

View File

@ -114,22 +114,6 @@ function dayDateLabel(d: Date, de: boolean): string {
});
}
// Strip Apple Calendar icon encodings (=h=g, =i=g, etc.) and emoji from
// event titles before PDF rendering. Helvetica cannot render these glyphs and
// they appear as raw escape sequences. The colored dot/chip already identifies
// the calendar visually, so the icon character carries no extra information.
function pdfTitle(s: string | null | undefined, maxLen = 60): string {
if (!s) return '';
return s
.replace(/(?:=[a-zA-Z])+/g, '') // Apple CalDAV icon escapes: =h=g, =i=g, =k=g …
.replace(/[\u{1F000}-\u{1FFFF}]/gu, '') // emoji (supplementary plane)
.replace(/[\u{2600}-\u{27BF}]/gu, '') // misc symbols & dingbats
.replace(/[\uE000-\uF8FF]/g, '') // Apple private-use area
.replace(/[\uFE00-\uFE0F\u200D]/g, '') // variation selectors / ZWJ
.trim()
.slice(0, maxLen);
}
function fmtHour(h: number): string {
return `${String(h).padStart(2, '0')}:00`;
}
@ -470,7 +454,7 @@ function WeekCalendarPDF({
return React.createElement(View, {
key: t.id,
style: { borderRadius: 2, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, backgroundColor: DONE_BG },
}, React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT } }, pdfTitle(t.title, 36)));
}, React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT } }, (t.title || '').slice(0, 36)));
}
if (t.isExternal) {
// Calendar event: solid color chip with white text + dot indicator (matches webapp style)
@ -489,7 +473,7 @@ function WeekCalendarPDF({
React.createElement(View, {
style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: evTextColor, opacity: 0.7, marginRight: 3, flexShrink: 0 },
}),
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: evTextColor, flex: 1 } }, pdfTitle(t.title, 36)),
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: evTextColor, flex: 1 } }, (t.title || '').slice(0, 36)),
);
}
// User-created all-day task: left border only, no background fill
@ -497,7 +481,7 @@ function WeekCalendarPDF({
return React.createElement(View, {
key: t.id,
style: { borderLeftWidth: 2, borderLeftColor: accent, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2 },
}, React.createElement(Text, { style: { fontSize: 7.5, color: '#374151' } }, pdfTitle(t.title, 36)));
}, React.createElement(Text, { style: { fontSize: 7.5, color: '#374151' } }, (t.title || '').slice(0, 36)));
}),
allDayTasks.length > 5 ? React.createElement(Text, {
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
@ -591,7 +575,7 @@ function WeekCalendarPDF({
key: t.id,
style: { marginBottom: 1, paddingLeft: 1, paddingVertical: 1 },
},
React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT, lineHeight: 1.3, textDecoration: 'line-through' } }, pdfTitle(t.title)),
React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT, lineHeight: 1.3, textDecoration: 'line-through' } }, (t.title || '').slice(0, 60)),
);
}
@ -605,7 +589,7 @@ function WeekCalendarPDF({
paddingLeft: 3, paddingVertical: 1, marginBottom: 1,
},
},
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, pdfTitle(t.title)),
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
);
}
@ -617,7 +601,7 @@ function WeekCalendarPDF({
paddingVertical: 1, marginBottom: 1,
},
},
React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, pdfTitle(t.title)),
React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
);
}),
);
@ -735,7 +719,7 @@ function WeekCalendarPDF({
}),
React.createElement(Text, {
style: { fontSize: 7, color: '#374151', lineHeight: 1.3, flex: 1 },
}, pdfTitle(t.title)),
}, (t.title || '').slice(0, 52)),
)
),
list.tasks.length > 18 ? React.createElement(Text, {

View File

@ -111,8 +111,6 @@ export async function GET(request: NextRequest) {
weatherLocation: true,
weatherRecentCities: true,
viewSettings: true,
customCssVars: true,
customCss: true,
hasCompletedOnboarding: true,
createdAt: true
}
@ -158,7 +156,6 @@ export async function PATCH(request: NextRequest) {
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages,
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
customCssVars, customCss,
hasCompletedOnboarding
} = body;
@ -259,8 +256,6 @@ export async function PATCH(request: NextRequest) {
...(weatherLocation !== undefined && { weatherLocation }),
...(weatherRecentCities !== undefined && { weatherRecentCities }),
...(viewSettings !== undefined && { viewSettings }),
...(customCssVars !== undefined && { customCssVars }),
...(customCss !== undefined && { customCss }),
...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }),
};
if (password && password.trim() !== "") {
@ -369,8 +364,6 @@ export async function PATCH(request: NextRequest) {
weatherLocation: true,
weatherRecentCities: true,
viewSettings: true,
customCssVars: true,
customCss: true,
hasCompletedOnboarding: true,
}
});

View File

@ -34,11 +34,10 @@ export default function SignupPage() {
}
try {
const language = (typeof navigator !== 'undefined' ? navigator.language : 'en').slice(0, 2).toLowerCase();
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, language }),
body: JSON.stringify({ name, email, password }),
});
const data = await response.json();

View File

@ -7,16 +7,13 @@ import Link from 'next/link';
function VerifyEmailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const initialEmail = searchParams.get('email') || '';
const tokenFromUrl = searchParams.get('token') || '';
const email = searchParams.get('email') || '';
const error = searchParams.get('error');
const [email, setEmail] = useState(initialEmail);
const [code, setCode] = useState(['', '', '', '', '', '']);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isResending, setIsResending] = useState(false);
const [isTokenVerifying, setIsTokenVerifying] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
@ -29,38 +26,10 @@ function VerifyEmailContent() {
}
}, [error]);
// Auto-focus first input (only if we're not in token-link mode)
// Auto-focus first input
useEffect(() => {
if (!tokenFromUrl) inputRefs.current[0]?.focus();
}, [tokenFromUrl]);
// Token-from-link flow: user landed here from the email button; we wait
// for them to click "Verify Email" rather than firing automatically, so
// inbox scanners that pre-fetch the URL never consume the token.
const verifyByToken = async () => {
setIsTokenVerifying(true);
setErrorMsg(null);
setMessage(null);
try {
const response = await fetch('/api/auth/verify-email', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokenFromUrl }),
});
const data = await response.json();
if (data.email && !email) setEmail(data.email);
if (!response.ok) {
setErrorMsg(data.error || 'Verification failed');
setIsTokenVerifying(false);
return;
}
setMessage('Email verified! Redirecting to login...');
setTimeout(() => router.push('/auth/login?verified=true'), 1500);
} catch {
setErrorMsg('Something went wrong. Please try again.');
setIsTokenVerifying(false);
}
};
inputRefs.current[0]?.focus();
}, []);
const handleInput = (index: number, value: string) => {
// Handle paste of full code
@ -171,15 +140,9 @@ function VerifyEmailContent() {
<h2 style={{ color: '#333', fontSize: '1.25rem', fontWeight: 600, textAlign: 'center', margin: '0 0 8px' }}>
Verify your email
</h2>
{tokenFromUrl ? (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
Click the button below to confirm{email ? <> <strong style={{ color: '#667eea' }}>{email}</strong></> : ' your email'}.
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email}</strong>
</p>
) : (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email || 'your email'}</strong>
</p>
)}
{/* Messages */}
{errorMsg && (
@ -202,38 +165,6 @@ function VerifyEmailContent() {
</div>
)}
{/* Email recovery field when we don't know it (e.g. landed via expired-link redirect).
Without this, the resend button has nothing to send to that was the actual UX
gap behind point #1. */}
{!email && (
<div className="weekly-auth-field" style={{ marginBottom: '16px' }}>
<input
type="email"
placeholder="Your email address"
autoComplete="email"
className="weekly-auth-input"
onChange={(e) => setEmail(e.target.value)}
/>
</div>
)}
{/* Token-link flow: prominent verify button */}
{tokenFromUrl && !message && (
<div style={{ marginBottom: '24px' }}>
<button
onClick={verifyByToken}
disabled={isTokenVerifying}
className="weekly-auth-button primary"
style={{ width: '100%' }}
>
{isTokenVerifying ? 'Verifying...' : 'Verify Email'}
</button>
<p style={{ color: '#888', fontSize: '0.8rem', textAlign: 'center', margin: '12px 0 0' }}>
Or enter the 6-digit code instead:
</p>
</div>
)}
{/* Code Input */}
<div style={{
display: 'flex',

View File

@ -32,7 +32,6 @@ body {
background-color: #f8fafc;
color: #1e293b;
line-height: 1.6;
letter-spacing: -0.005em;
transition: background-color 0.3s ease;
}
@ -475,46 +474,6 @@ h3 {
--weekly-settings-toggle-active-text: #111827;
--weekly-header-brightness: 1;
--weekly-goal-brightness: 1;
/* ── v2 design token aliases ── */
/* Map to existing weekly vars where a match exists */
--bg: var(--weekly-bg);
--paper: var(--weekly-bg);
--ink: var(--weekly-text);
--ink-2: #2a2a28;
--ink-3: var(--weekly-text-light);
--ink-4: #a8a8a2;
--ink-5: #c8c8c2;
--accent: var(--weekly-teal);
--accent-soft: rgba(0, 154, 154, 0.08);
--line: var(--weekly-border);
--line-soft: #f3f3f0;
--weekend: #dc2626;
--tasks-bg: #f4f2ee;
/* Calendar-source event palette */
--ev-default-bg: #eef2f7;
--ev-default-border: #6c87a8;
--ev-default-title: #2b3f57;
--ev-default-meta: #6c87a8;
--ev-fam-bg: #fbeef0;
--ev-fam-border: #b85a6a;
--ev-fam-title: #5a2530;
--ev-fam-meta: #99536a;
--ev-fin-bg: #f6f0e2;
--ev-fin-border: #a88a3c;
--ev-fin-title: #4a3a14;
--ev-fin-meta: #8a6f2a;
--ev-dev-bg: #ecf3ed;
--ev-dev-border: #5a7a4a;
--ev-dev-title: #2c4527;
--ev-dev-meta: #5a7a4a;
/* All-day chip palette */
--chip-default-bg: #b8b8b0;
--chip-fam-bg: #e88a8a;
--chip-special-bg: #f4d588;
--chip-special-text: #6b4f10;
}
/* Weekly Main Container */
@ -533,7 +492,7 @@ h3 {
top: 0;
z-index: 100;
background: var(--weekly-bg);
border-bottom: 1px solid var(--line);
border-bottom: 1px solid var(--weekly-border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
@ -1278,7 +1237,7 @@ h3 {
.weekly-someday {
background-color: #f9f9f9;
width: 100%;
padding: 0;
padding: 0 1rem;
position: relative;
flex-shrink: 0;
}
@ -1360,15 +1319,14 @@ h3 {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: 12px;
gap: 1.5rem;
width: max-content;
min-width: 100%;
padding: 12px 12px 16px;
padding-bottom: 1rem;
align-items: flex-start;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overflow: visible;
background-color: #F9F9F9;
overflow: visible; /* Let parent container handle scrolling */
}
/* Scrollbar Styling for Someday Container */
@ -1414,45 +1372,37 @@ h3 {
display: none;
}
/* Someday list cards */
/* Ruled paper lines for someday tasks */
.weekly-someday-list {
padding: 0;
min-height: 200px;
flex: 0 0 300px;
flex: 0 0 300px; /* Slightly wider lists */
width: 300px;
transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
transition: transform 0.2s ease, opacity 0.2s ease;
max-width: 100%;
display: flex;
flex-direction: column;
overflow-y: visible;
scrollbar-width: none;
-ms-overflow-style: none;
background: var(--weekly-bg, #fff);
border: 1px solid var(--weekly-border, #e5e7eb);
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow: hidden;
scrollbar-width: none; /* Hide scrollbar Firefox */
-ms-overflow-style: none; /* Hide scrollbar IE/Edge */
}
.weekly-someday-list::-webkit-scrollbar {
display: none;
display: none; /* Hide scrollbar Chrome/Safari/Webkit */
}
.weekly-container.dark-mode .weekly-someday-list {
background-image: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.task-list-slot {
height: 38px;
width: 100%;
position: relative;
display: flex;
align-items: center;
border-bottom: 1px solid var(--weekly-border);
box-sizing: border-box;
padding-top: 4px;
padding-bottom: 4px;
min-height: 38px;
}
.task-list-slot.drop-target {
@ -1513,7 +1463,7 @@ h3 {
}
.weekly-someday-list:last-child {
/* gap handles spacing, no extra right border needed */
border-right: none;
}
.weekly-someday-list.is-dragging {
@ -1523,9 +1473,9 @@ h3 {
.weekly-someday-list .weekly-task-item {
border-bottom: none;
min-height: 24px;
min-height: 38px;
height: auto;
padding: 4px 4px;
padding: 6px 1rem;
display: flex;
align-items: flex-start;
overflow: visible !important;
@ -1545,38 +1495,36 @@ h3 {
overflow: visible;
white-space: normal;
text-overflow: clip;
padding-left: 4px;
padding: 0;
}
.weekly-someday-list-title-header {
padding: 10px 12px;
padding: 0 1rem;
margin-bottom: 0;
min-height: 48px;
height: 56px;
min-height: 56px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--weekly-border);
background: rgba(0, 0, 0, 0.02);
}
.weekly-container.dark-mode .weekly-someday-list-title-header {
border-bottom-color: var(--weekly-border);
background: rgba(255, 255, 255, 0.03);
}
.weekly-someday-list-title-input {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.06em;
font-size: 1.15rem;
font-weight: 800;
letter-spacing: 0.08em;
color: var(--weekly-text, #222);
background: transparent;
border: none;
flex: 1;
min-width: 0;
padding: 2px 0;
padding: 4px 0;
outline: none;
transition: opacity 0.15s ease;
text-transform: uppercase;
}
.weekly-someday .weekly-task-item {
@ -1594,29 +1542,29 @@ h3 {
.someday-add-task-btn {
background: none;
border: none;
color: #bbb;
color: #aaa;
cursor: pointer;
font-size: 0.8rem;
padding: 8px 12px;
font-size: 0.85rem;
padding: 4px 0;
text-align: left;
width: 100%;
transition: color 0.15s ease;
}
.someday-add-task-btn:hover {
color: #888;
color: #666;
}
/* Someday Tabs Bar (horizontal above grid) */
.someday-tabs-bar {
display: flex;
flex-direction: row;
align-items: flex-end;
align-items: center;
gap: 4px;
padding: 0px 12px 0;
padding: 4px 8px;
border-bottom: 1px solid var(--weekly-border);
overflow-x: auto;
scrollbar-width: none;
background-color: #EEEEEE;
}
.someday-tabs-bar::-webkit-scrollbar {
@ -1628,14 +1576,13 @@ h3 {
border: none;
cursor: pointer;
color: var(--weekly-text-light, #999);
padding: 5px;
border-radius: 6px;
padding: 3px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
padding:8px;
}
.someday-bar-icon-btn:hover {
@ -1645,69 +1592,60 @@ h3 {
.someday-tabs-bar-divider {
width: 1px;
height: 34px;
height: 16px;
background: var(--weekly-border, #e5e7eb);
flex-shrink: 0;
margin: 0 4px;
margin: 0 2px;
}
/* Someday Tab Buttons (horizontal) — pill + underline style */
/* Someday Tab Buttons (horizontal) */
.someday-tab-btn-h {
background: none;
border: none;
cursor: pointer;
color: var(--weekly-text-light, #888);
font-size: 0.8rem;
color: var(--weekly-text-light, #999);
font-size: 0.75rem;
font-weight: 500;
padding: 5px 12px 4px;
border-radius: 6px 6px 0 0;
padding: 3px 10px;
border-radius: 3px;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 4px;
transition: background 0.15s, color 0.15s;
}
.someday-tab-btn-h:hover {
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
color: var(--weekly-text, #333);
border-color: var(--weekly-border, #e5e7eb);
}
.someday-tab-btn-h.active {
background: #f9f9f9;
color: var(--weekly-text, #222);
font-weight: 700;
box-shadow: 2px 2px 10px rgba(0,0,0,0.1);
}
.weekly-container.dark-mode .someday-tab-btn-h.active {
background: #1a1a1b;
border-bottom-color: #1a1a1b;
background: var(--weekly-accent, #6366f1);
color: #fff;
}
.someday-tab-btn-h.drag-over {
outline: 2px dashed var(--weekly-accent, #6366f1);
outline-offset: -1px;
background: rgba(99, 102, 241, 0.12);
}
.someday-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: rgba(0, 0, 0, 0.07);
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.08));
font-size: 0.65rem;
font-weight: 700;
font-weight: 600;
line-height: 1;
color: var(--weekly-text-light, #888);
margin-left: 2px;
}
.someday-tab-btn-h.active .someday-tab-count {
background: rgba(99, 102, 241, 0.12);
color: var(--weekly-accent, #000);
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
/* Someday Tab Wrapper with dissolve button */
@ -1720,7 +1658,7 @@ h3 {
.someday-tab-dissolve-h {
position: absolute;
right: -4px;
top: -6px;
top: -4px;
background: var(--weekly-bg, #fff);
border: 1px solid var(--weekly-border, #e5e7eb);
border-radius: 50%;
@ -2955,7 +2893,7 @@ h3 {
}
.time-slot-label span {
transform: translateY(0%); /* Centers the label on the grid line */
transform: translateY(-50%); /* Centers the label on the grid line */
background: var(--weekly-bg);
padding: 0 4px;
z-index: 1;
@ -3270,29 +3208,26 @@ h3 {
/* Resize handle for draggable section borders */
.resize-handle {
height: 2px;
border: 1px solid #ccc;
height: 7px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
touch-action: none;
position: relative;
z-index: 50;
z-index: 10;
flex-shrink: 0;
overflow: visible;
background-color: #ddd;
border-top: 1px solid var(--weekly-border, #e5e7eb);
border-bottom: 1px solid var(--weekly-border, #e5e7eb);
}
.resize-handle:hover {
background-color: #d7d7d7;
border-color: #b8b8b8;
background: rgba(59, 130, 246, 0.08);
border-color: var(--weekly-accent, #3b82f6);
}
.resize-handle:active {
background-color: #d1d1d1;
border-color: #aaa;
background: rgba(59, 130, 246, 0.12);
border-color: var(--weekly-accent, #3b82f6);
}
.resize-handle-bar {
width: 32px;
@ -3302,50 +3237,10 @@ h3 {
opacity: 0.6;
transition: background 0.15s, opacity 0.15s;
}
.resize-handle-anyday {
height: 2px;
}
.resize-handle-allday {
height: 2px;
}
.resize-handle-anyday .resize-handle-bar {
width: 23px;
height: 9px;
border-radius: 4px;
background: radial-gradient(circle, color-mix(in srgb, var(--weekly-text, #333) 48%, transparent) 1.25px, transparent 1.35px) 2px 1px / 6px 5px repeat;
background-color: #ddd;
border: 1px solid #ccc;
opacity: 1;
position: relative;
bottom: 0;
overflow: visible;
z-index: 50;
}
.resize-handle-allday .resize-handle-bar {
width: 23px;
height: 9px;
border-radius: 4px;
background: radial-gradient(circle, color-mix(in srgb, var(--weekly-text, #333) 48%, transparent) 1.25px, transparent 1.35px) 2px 1px / 6px 5px repeat;
background-color: #ddd;
border: 1px solid #ccc;
opacity: 1;
position: relative;
bottom: 0;
overflow: visible;
z-index: 50;
}
.resize-handle:hover .resize-handle-bar,
.resize-handle:active .resize-handle-bar {
opacity: 1;
}
.resize-handle-anyday:hover .resize-handle-bar,
.resize-handle-anyday:active .resize-handle-bar,
.resize-handle-allday:hover .resize-handle-bar,
.resize-handle-allday:active .resize-handle-bar {
background: radial-gradient(circle, var(--weekly-accent, #3b82f6) 1.25px, transparent 1.35px) 2px 1.5px / 6px 5px repeat;
background-color: #d7d7d7;
border-color: #b8b8b8;
opacity: 1;
background: var(--weekly-accent, #3b82f6);
opacity: 0.5;
}
.all-day-events-header {
@ -4834,42 +4729,6 @@ h3 {
opacity: 0.7;
}
/* --- Mobile Left Sidebar Rail --- */
.mobile-left-sidebar {
scrollbar-width: none;
}
.mobile-left-sidebar::-webkit-scrollbar {
display: none;
}
.mobile-rail-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
border: none;
background: none;
cursor: pointer;
color: var(--weekly-text-light, #888);
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.mobile-rail-btn:hover,
.mobile-rail-btn:active {
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.07));
color: var(--weekly-text, #333);
}
.dark-mode .mobile-rail-btn:hover,
.dark-mode .mobile-rail-btn:active {
background: rgba(255, 255, 255, 0.08);
color: #e5e7eb;
}
.mobile-rail-backdrop {
-webkit-tap-highlight-color: transparent;
}
/* --- Mobile Quick Actions in Sidebar --- */
.mobile-quick-nav-btn {
min-width: 44px;

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, lazy, Suspense, useRef, useCallback } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity, Check } from 'lucide-react';
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faServer } from '@fortawesome/free-solid-svg-icons';
@ -27,7 +27,7 @@ function AnimatedDots() {
}
// Reminder preset options (minutes)
const REMINDER_PRESETS = [
const REMINDER_OPTIONS = [
{ label: 'None', value: -1 },
{ label: 'At time of event', value: 0 },
{ label: '5 minutes before', value: 5 },
@ -40,25 +40,6 @@ const REMINDER_PRESETS = [
{ label: '1 week before', value: 10080 },
];
const CUSTOM_REMINDER_SENTINEL = -9999;
function formatReminderMinutes(minutes: number, language: string): string {
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
if (minutes % 10080 === 0) {
const w = minutes / 10080;
return language === 'de' ? `${w} ${w === 1 ? 'Woche' : 'Wochen'} vorher` : `${w} week${w !== 1 ? 's' : ''} before`;
}
if (minutes % 1440 === 0) {
const d = minutes / 1440;
return language === 'de' ? `${d} ${d === 1 ? 'Tag' : 'Tage'} vorher` : `${d} day${d !== 1 ? 's' : ''} before`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return language === 'de' ? `${h} ${h === 1 ? 'Stunde' : 'Stunden'} vorher` : `${h} hour${h !== 1 ? 's' : ''} before`;
}
return language === 'de' ? `${minutes} Minuten vorher` : `${minutes} minutes before`;
}
const BUSY_STATUS_OPTIONS = [
{ label: 'Busy', value: 'busy' },
{ label: 'Free', value: 'free' },
@ -82,10 +63,6 @@ interface CalendarEventModalProps {
connections: any[];
weekStartDay?: number; // 0=Sunday, 1=Monday
language?: string;
savedLocations?: string[];
onSaveLocation?: (loc: string) => void;
customReminderMinutes?: number[];
onSaveCustomReminder?: (minutes: number) => void;
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
@ -99,10 +76,6 @@ export default function CalendarEventModal({
connections,
weekStartDay = 0,
language = 'en',
savedLocations = [],
onSaveLocation,
customReminderMinutes = [],
onSaveCustomReminder,
onClose,
onSave,
onDelete
@ -194,53 +167,6 @@ export default function CalendarEventModal({
const calendarSelectorRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Location autocomplete
const [locationFocused, setLocationFocused] = useState(false);
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]);
const locationRef = useRef<HTMLDivElement>(null);
const updateLocationSuggestions = useCallback((val: string) => {
if (!val.trim() || savedLocations.length === 0) {
setLocationSuggestions([]);
return;
}
const lower = val.toLowerCase();
const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val);
setLocationSuggestions(matches.slice(0, 6));
}, [savedLocations]);
// Custom reminder state
// customReminderIdx tracks which reminder row is showing the custom editor
const [customEditorIdx, setCustomEditorIdx] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState(30);
const [reminderUnit, setReminderUnit] = useState<'minutes' | 'hours' | 'days'>('minutes');
const customAmountToMinutes = () => {
if (reminderUnit === 'hours') return customAmount * 60;
if (reminderUnit === 'days') return customAmount * 1440;
return customAmount;
};
// Build full reminder options list: presets + saved custom + "Custom..."
const buildReminderOptions = () => [
...REMINDER_PRESETS,
...customReminderMinutes
.filter(m => !REMINDER_PRESETS.some(p => p.value === m))
.map(m => ({ label: formatReminderMinutes(m, language), value: m })),
{ label: language === 'de' ? 'Benutzerdefiniert…' : 'Custom…', value: CUSTOM_REMINDER_SENTINEL },
];
// Close location suggestions on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (locationRef.current && !locationRef.current.contains(e.target as Node)) {
setLocationSuggestions([]);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Focus trap
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -308,7 +234,7 @@ export default function CalendarEventModal({
end: { dateTime: endDate.toISOString() },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
reminders: activeReminders.length > 0 ? activeReminders : undefined,
busyStatus: busyStatus,
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
visibility: visibility !== 'default' ? visibility : undefined,
attendees: attendees.length > 0 ? attendees : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
@ -335,11 +261,6 @@ export default function CalendarEventModal({
return;
}
// Save location to user profile if non-empty and new
if (location.trim() && onSaveLocation) {
onSaveLocation(location.trim());
}
setIsSaving(true);
setError('');
try {
@ -427,29 +348,11 @@ export default function CalendarEventModal({
const updateReminder = (index: number, minutes: number) => {
if (minutes === -1) {
setReminders(reminders.filter((_, i) => i !== index));
setCustomEditorIdx(null);
return;
}
if (minutes === CUSTOM_REMINDER_SENTINEL) {
setCustomEditorIdx(index);
setCustomAmount(30);
setReminderUnit('minutes');
return;
}
const updated = [...reminders];
updated[index] = { ...updated[index], minutes };
setReminders(updated);
setCustomEditorIdx(null);
};
const confirmCustomReminder = (index: number) => {
const mins = customAmountToMinutes();
if (mins <= 0) return;
const updated = [...reminders];
updated[index] = { ...updated[index], minutes: mins };
setReminders(updated);
setCustomEditorIdx(null);
if (onSaveCustomReminder) onSaveCustomReminder(mins);
};
const addReminder = () => {
@ -784,61 +687,24 @@ export default function CalendarEventModal({
</div>
)}
{/* Location with autocomplete */}
<div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
{/* Location */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
<input
type="text"
value={location}
onChange={e => { setLocation(e.target.value); updateLocationSuggestions(e.target.value); }}
onFocus={() => { setLocationFocused(true); updateLocationSuggestions(location); }}
onBlur={() => setTimeout(() => setLocationSuggestions([]), 150)}
onKeyDown={e => { if (e.key === 'Escape') setLocationSuggestions([]); }}
onChange={e => setLocation(e.target.value)}
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
aria-label={language === 'de' ? 'Ort' : 'Location'}
aria-autocomplete="list"
aria-expanded={locationSuggestions.length > 0}
style={{
...fieldCol, padding: '2px 0', border: 'none',
background: 'transparent', outline: 'none', fontSize: '0.8rem',
color: 'var(--weekly-text)',
}}
/>
{locationSuggestions.length > 0 && (
<div
role="listbox"
aria-label={language === 'de' ? 'Ortsvorschläge' : 'Location suggestions'}
style={{
position: 'absolute', top: '100%', left: '26px', right: 0,
zIndex: 200, background: 'var(--weekly-bg-popover, #fff)',
border: '1px solid var(--weekly-border, #e5e7eb)',
borderRadius: '8px', boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
overflow: 'hidden', marginTop: '2px',
}}
>
{locationSuggestions.map((suggestion, i) => (
<div
key={i}
role="option"
aria-selected={false}
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }}
style={{
padding: '6px 10px', fontSize: '0.78rem',
cursor: 'pointer', color: 'var(--weekly-text)',
display: 'flex', alignItems: 'center', gap: '6px',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--weekly-hover, rgba(0,0,0,0.05))')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion}</span>
</div>
))}
</div>
)}
</div>
{/* Alert / Reminders */}
{/* Alert */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
@ -848,80 +714,24 @@ export default function CalendarEventModal({
</button>
) : (
reminders.map((reminder, idx) => (
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<select
value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
value={reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))}
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
style={{ ...inlineSelect, flex: 1 }}
>
{buildReminderOptions().map(opt => (
{REMINDER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
<X size={12} aria-hidden="true" />
</button>
</div>
{/* Custom reminder editor row */}
{customEditorIdx === idx && (
<div style={{
display: 'flex', alignItems: 'center', gap: '4px',
paddingLeft: '0', background: 'var(--weekly-bg-secondary, #f3f4f6)',
borderRadius: '6px', padding: '4px 6px',
}}>
<input
type="number"
min={1}
max={9999}
value={customAmount}
onChange={e => setCustomAmount(Math.max(1, parseInt(e.target.value) || 1))}
aria-label={language === 'de' ? 'Erinnerungsmenge' : 'Reminder amount'}
style={{
width: '48px', textAlign: 'center', fontSize: '0.78rem',
background: 'var(--weekly-bg, #fff)', borderRadius: '5px',
padding: '2px 4px', border: '1px solid var(--weekly-border, #ddd)',
outline: 'none', color: 'var(--weekly-text)',
}}
/>
<select
value={reminderUnit}
onChange={e => setReminderUnit(e.target.value as any)}
aria-label={language === 'de' ? 'Erinnerungseinheit' : 'Reminder unit'}
style={{ ...inlineSelect, flex: 1, fontSize: '0.75rem' }}
>
<option value="minutes">{language === 'de' ? 'Minuten' : 'minutes'}</option>
<option value="hours">{language === 'de' ? 'Stunden' : 'hours'}</option>
<option value="days">{language === 'de' ? 'Tage' : 'days'}</option>
</select>
<span style={{ fontSize: '0.7rem', color: 'var(--weekly-text-light)', whiteSpace: 'nowrap' }}>
{language === 'de' ? 'vorher' : 'before'}
</span>
<button
onClick={() => confirmCustomReminder(idx)}
title={language === 'de' ? 'Bestätigen' : 'Confirm'}
style={{
background: '#3b82f6', border: 'none', borderRadius: '5px',
cursor: 'pointer', padding: '3px 7px', display: 'flex', alignItems: 'center',
color: '#fff', flexShrink: 0,
}}
>
<Check size={12} />
</button>
<button
onClick={() => setCustomEditorIdx(null)}
title={language === 'de' ? 'Abbrechen' : 'Cancel'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.6 }}
>
<X size={12} />
</button>
</div>
)}
</div>
))
)}
{reminders.length > 0 && reminders.length < 2 && (

View File

@ -1,94 +0,0 @@
"use client";
import React from "react";
// Tiny inline SVG country flags. Used by the language pickers so flags
// render identically on every OS (Windows ships without the regional
// emoji glyphs that "🇬🇧" relies on).
type FlagCode = "gb" | "de" | "fr" | "es" | "it";
const flags: Record<FlagCode, React.ReactNode> = {
// United Kingdom
gb: (
<svg viewBox="0 0 60 30" width="100%" height="100%">
<clipPath id="fl_gb_t"><path d="M30,15h30v15z v15h-30z h-30v-15z v-15h30z"/></clipPath>
<path d="M0,0v30h60v-30z" fill="#012169"/>
<path d="M0,0 60,30 M60,0 0,30" stroke="#fff" strokeWidth="6"/>
<path d="M0,0 60,30 M60,0 0,30" clipPath="url(#fl_gb_t)" stroke="#C8102E" strokeWidth="4"/>
<path d="M30,0v30 M0,15h60" stroke="#fff" strokeWidth="10"/>
<path d="M30,0v30 M0,15h60" stroke="#C8102E" strokeWidth="6"/>
</svg>
),
// Germany
de: (
<svg viewBox="0 0 5 3" width="100%" height="100%" preserveAspectRatio="none">
<rect width="5" height="3" fill="#000"/>
<rect width="5" height="2" y="1" fill="#D00"/>
<rect width="5" height="1" y="2" fill="#FFCE00"/>
</svg>
),
// France
fr: (
<svg viewBox="0 0 3 2" width="100%" height="100%" preserveAspectRatio="none">
<rect width="1" height="2" x="0" fill="#0055A4"/>
<rect width="1" height="2" x="1" fill="#fff"/>
<rect width="1" height="2" x="2" fill="#EF4135"/>
</svg>
),
// Spain
es: (
<svg viewBox="0 0 5 3" width="100%" height="100%" preserveAspectRatio="none">
<rect width="5" height="3" fill="#AA151B"/>
<rect width="5" height="1.5" y="0.75" fill="#F1BF00"/>
</svg>
),
// Italy
it: (
<svg viewBox="0 0 3 2" width="100%" height="100%" preserveAspectRatio="none">
<rect width="1" height="2" x="0" fill="#009246"/>
<rect width="1" height="2" x="1" fill="#fff"/>
<rect width="1" height="2" x="2" fill="#CE2B37"/>
</svg>
),
};
// Map UI language codes to flag codes (English uses the British flag per user request).
const LANG_FLAG: Record<string, FlagCode> = {
en: "gb",
de: "de",
fr: "fr",
es: "es",
it: "it",
};
interface FlagIconProps {
code: string;
width?: number;
height?: number;
className?: string;
}
export default function FlagIcon({ code, width = 22, height = 16, className }: FlagIconProps) {
const c = (LANG_FLAG[code] || code) as FlagCode;
const flag = flags[c];
if (!flag) return null;
return (
<span
className={className}
style={{
display: "inline-block",
width,
height,
lineHeight: 0,
borderRadius: 2,
overflow: "hidden",
boxShadow: "0 0 0 1px rgba(0,0,0,0.06)",
flexShrink: 0,
verticalAlign: "middle",
}}
aria-hidden="true"
>
{flag}
</span>
);
}

View File

@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, Suspense } from "react";
import dynamic from "next/dynamic";
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
import { Repeat, X, ChevronDown, Link } from "lucide-react";
import { Repeat, Circle, X, ChevronDown, Link } from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
@ -267,10 +267,10 @@ export function GridTaskBlock({
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
if (stageColor) return `4px solid ${stageColor}`;
if (task.project?.color) return `4px solid ${task.project.color}`;
return `4px solid transparent`;
return (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none";
})(),
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
padding: weatherEnabled ? "2px 30px 2px 8px" : "2px 4px 2px 8px",
padding: (task.kanbanStage && kanbanStages.some(s => s.id === task.kanbanStage)) || task.project?.color ? "2px 4px 2px 8px" : "2px 4px",
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
display: "flex",
flexDirection: "column",
@ -299,7 +299,7 @@ export function GridTaskBlock({
if (editingTaskId !== task.id) toggleTask(task.id);
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
{editingTaskId === task.id ? (
<form
onSubmit={(e) => {
@ -329,7 +329,7 @@ export function GridTaskBlock({
<span
style={{
display: "flex",
alignItems: "center",
alignItems: "flex-start",
gap: "3px",
overflow: "visible",
whiteSpace: "pre-wrap",
@ -341,37 +341,6 @@ export function GridTaskBlock({
setEditingTaskId(task.id);
}}
>
{(showPriorityIcons || showProjectIcons) && (
<span style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "14px", minWidth: "14px", flexShrink: 0, gap: "2px", opacity: task.completed && showTaskCheckboxes ? 0.5 : 1 }}>
{showPriorityIcons && (() => {
const pb = getPriorityBadge(task, priorityStyle, 11);
return pb ? (
<span title={pb.label} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: "12px", height: "12px" }}>
{pb.node}
</span>
) : null;
})()}
{showProjectIcons && task.project && (() => {
const rawIcon = task.project!.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
return (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: "12px", height: "12px" }}>
{found
? (found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ display: "inline-block" }} />)
: (task.project!.icon
? <span style={{ fontSize: "11px" }}>{task.project!.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />)
}
</span>
);
})()}
</span>
)}
{showTaskCheckboxes && (
<input
type="checkbox"
@ -380,11 +349,34 @@ export function GridTaskBlock({
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0"
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", flexShrink: 0, accentColor: "#FFF" }}
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }}
/>
)}
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
<span style={{ marginRight: "4px" }}>{task.title}</span>
{showPriorityIcons && (() => {
const pb = getPriorityBadge(task, priorityStyle, 11);
return pb ? (
<span title={pb.label} style={{ marginRight: "4px", display: "inline-flex", alignItems: "center", flexShrink: 0 }}>
{pb.node}
</span>
) : null;
})()}
<span style={{ marginRight: "4px" }}>{showProjectIcons && task.project && (() => {
const rawIcon = task.project!.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
const iconStyle = { marginRight: "4px", verticalAlign: "middle" } as const;
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ ...iconStyle, display: "inline-block" }} />;
}
return task.project!.icon
? <span style={{ fontSize: "11px", ...iconStyle }}>{task.project!.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />;
})()}{task.title}</span>
{/* Subtask indicator */}
{task.subTasks && task.subTasks.length > 0 && (() => {
const completed = task.subTasks.filter(s => s.completed).length;
@ -496,42 +488,53 @@ export function GridTaskBlock({
</svg>
</button>
)}
</span>
</span>
)}
{editingTaskId !== task.id && (() => {
const rolling = task.isRolling && !task.completed;
const provider = task.externalProvider || (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!rolling && !provider) return null;
{weatherEnabled && (() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
apple: { icon: faApple, color: "#555", label: "Apple" },
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
};
return (
<span style={{ display: "flex", alignItems: "center", gap: "3px", flexShrink: 0 }}>
{rolling && (
<span title="Auto-rolling task" style={{ display: "inline-flex", alignItems: "center", color: "var(--weekly-teal, #009a9a)" }}>
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</span>
)}
{provider && (() => {
const info = iconMap[provider];
if (!info) return null;
return (
<span className="flex-shrink-0" aria-label={`Synced with ${info.label}`} style={{ display: "inline-flex", alignItems: "center", opacity: 0.55 }}>
<span
className="flex-shrink-0"
aria-label={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "3px" }}
>
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
</span>
</span>
)}
</div>
{!weatherEnabled && (() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
apple: { icon: faApple, color: "#555", label: "Apple" },
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
};
const info = iconMap[provider];
if (!info) return null;
return (
<span
aria-label={`Synced with ${info.label}`}
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
>
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
</div>
<div
className="task-actions"
@ -615,21 +618,13 @@ export function GridTaskBlock({
aria-expanded={showProjectPicker}
aria-haspopup="listbox"
>
{task.project ? (() => {
const rawIcon = task.project.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project.color || "#888"} style={{ display: "inline-block" }} />;
}
return task.project.icon
? <span style={{ fontSize: "11px", color: task.project.color || "#888" }}>{task.project.icon}</span>
: <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />;
})() : <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px" }} />}
<Circle
size={12}
aria-hidden="true"
fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
/>
</button>
{showProjectPicker && (
<div className="absolute z-[100] top-full left-1/2 -translate-x-1/2 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
@ -655,21 +650,7 @@ export function GridTaskBlock({
setShowProjectPicker(false);
}}
>
{(() => {
const rawIcon = p.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "10px", color: p.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.45} color={p.color || "#888"} style={{ display: "inline-block" }} />;
}
return p.icon
? <span style={{ fontSize: "10px", color: p.color || "#888" }}>{p.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "10px", color: p.color || "#888" }} />;
})()}
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
{p.name}
</button>
))}

View File

@ -5,7 +5,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle, faApple, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import FontPicker from "./FontPicker";
import FlagIcon from "./FlagIcon";
interface OnboardingWizardProps {
profile: any;
@ -756,7 +755,7 @@ export default function OnboardingWizard({
transition: "all 0.15s",
}}
>
<FlagIcon code={lang.code} width={22} height={16} />
<span style={{ fontWeight: 700, fontSize: "0.75rem", background: darkMode ? "#374151" : "#e5e7eb", padding: "2px 6px", borderRadius: "4px" }}>{lang.flag}</span>
{lang.label}
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
</button>

View File

@ -1,201 +0,0 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Search, X } from "lucide-react";
// Generic select-like dropdown that supports arbitrary node rendering for
// each option (so we can show flags, offsets, etc.) and an optional search
// box that filters the list against `searchHaystack`.
export interface DropdownOption<T = string> {
value: T;
label: string;
searchHaystack?: string; // extra text to match search against (city, country, abbreviation…)
leading?: React.ReactNode;
secondary?: string; // dimmer text after the label
}
interface SearchableDropdownProps<T = string> {
value: T;
options: DropdownOption<T>[];
onChange: (value: T) => void;
placeholder?: string;
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
className?: string;
style?: React.CSSProperties;
width?: string | number;
darkMode?: boolean;
}
export default function SearchableDropdown<T extends string>({
value,
options,
onChange,
placeholder,
searchable = false,
searchPlaceholder = "Search…",
emptyText = "No matches",
className,
style,
width = "100%",
darkMode = false,
}: SearchableDropdownProps<T>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (open && searchable) setTimeout(() => searchRef.current?.focus(), 30);
}, [open, searchable]);
const selected = options.find((o) => o.value === value);
const filtered = useMemo(() => {
if (!query.trim()) return options;
const q = query.toLowerCase();
return options.filter((o) => {
const hay = (o.searchHaystack || `${o.label} ${o.secondary || ""}`).toLowerCase();
return hay.includes(q);
});
}, [options, query]);
const bg = darkMode ? "#1f2937" : "#ffffff";
const border = darkMode ? "#374151" : "#d1d5db";
const text = darkMode ? "#e5e7eb" : "#111827";
const muted = darkMode ? "#9ca3af" : "#6b7280";
const hoverBg = darkMode ? "#374151" : "#f3f4f6";
const activeBg = darkMode ? "rgba(13,148,136,0.18)" : "rgba(13,148,136,0.08)";
return (
<div ref={wrapperRef} className={className} style={{ position: "relative", width, ...style }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 6,
border: `1px solid ${border}`,
background: bg,
color: text,
cursor: "pointer",
fontSize: "0.9rem",
textAlign: "left",
}}
>
{selected?.leading && <span style={{ display: "inline-flex" }}>{selected.leading}</span>}
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{selected ? selected.label : (placeholder || "Select…")}
</span>
{selected?.secondary && (
<span style={{ color: muted, fontSize: "0.8rem", whiteSpace: "nowrap" }}>{selected.secondary}</span>
)}
<ChevronDown size={14} style={{ color: muted, flexShrink: 0, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }} />
</button>
{open && (
<div
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 1000,
background: bg,
border: `1px solid ${border}`,
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
maxHeight: 320,
}}
>
{searchable && (
<div style={{ padding: 8, borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", gap: 6, background: bg }}>
<Search size={14} style={{ color: muted, flexShrink: 0 }} />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
style={{
flex: 1,
border: "none",
outline: "none",
background: "transparent",
color: text,
fontSize: "0.85rem",
}}
/>
{query && (
<button
onClick={() => setQuery("")}
style={{ background: "none", border: "none", cursor: "pointer", color: muted, padding: 2 }}
>
<X size={12} />
</button>
)}
</div>
)}
<div style={{ overflowY: "auto", flex: 1 }}>
{filtered.length === 0 ? (
<div style={{ padding: 16, color: muted, fontSize: "0.85rem", textAlign: "center" }}>{emptyText}</div>
) : filtered.map((opt) => {
const active = opt.value === value;
return (
<button
key={String(opt.value)}
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
setQuery("");
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
border: "none",
background: active ? activeBg : "transparent",
color: text,
cursor: "pointer",
fontSize: "0.85rem",
textAlign: "left",
}}
onMouseEnter={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = hoverBg; }}
onMouseLeave={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
>
{opt.leading && <span style={{ display: "inline-flex", flexShrink: 0 }}>{opt.leading}</span>}
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{opt.label}</span>
{opt.secondary && <span style={{ color: muted, fontSize: "0.78rem", whiteSpace: "nowrap" }}>{opt.secondary}</span>}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -35,357 +35,6 @@ import {
import IconPicker from "./IconPicker";
import { allIcons } from "./iconRegistry";
import Icon from "@mdi/react";
import FlagIcon from "./FlagIcon";
import SearchableDropdown from "./SearchableDropdown";
import { TIMEZONE_OPTIONS, formatTimezone, getOffsetMinutes, formatOffset } from "../lib/timezones";
// Punkt 6 — manage a user's extra timezones (for cross-team scheduling).
// Stored in user.viewSettings.extraTimezones to avoid an extra migration.
function ExtraTimezonesEditor({
profile,
setProfile,
saveSetting,
}: {
profile: any;
setProfile: React.Dispatch<React.SetStateAction<any>>;
saveSetting: (key: string, value: any) => void;
}) {
const de = profile.language === "de";
const extras: string[] = (profile.viewSettings && profile.viewSettings.extraTimezones) || [];
const [picker, setPicker] = useState("");
const persist = (next: string[]) => {
const newViewSettings = { ...(profile.viewSettings || {}), extraTimezones: next };
setProfile((p: any) => ({ ...p, viewSettings: newViewSettings }));
saveSetting("viewSettings", newViewSettings);
};
const add = (zone: string) => {
if (!zone || extras.includes(zone) || zone === profile.timezone) return;
persist([...extras, zone]);
setPicker("");
};
const remove = (zone: string) => {
persist(extras.filter((z) => z !== zone));
};
return (
<div style={{ marginTop: "10px" }}>
<label style={{ display: "block", fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", marginBottom: "4px" }}>
{de ? "Zusätzliche Zeitzonen" : "Additional Timezones"}
</label>
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 6px", opacity: 0.8 }}>
{de
? "Hilfreich, wenn du mit Teammitgliedern in anderen Zeitzonen arbeitest. Zeiten werden im Tageskopf angezeigt."
: "Useful when collaborating with people in other timezones. Times are shown in the day header."}
</p>
{extras.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "4px", marginBottom: "6px" }}>
{extras.map((z) => {
const opt = TIMEZONE_OPTIONS.find((o) => o.zone === z);
return (
<div key={z} style={{
display: "flex", alignItems: "center", gap: "8px",
padding: "4px 8px", borderRadius: "6px",
background: "var(--weekly-bg-soft, #f9fafb)",
border: "1px solid var(--weekly-border, #e5e7eb)",
}}>
<span style={{ fontSize: "0.8rem", flex: 1 }}>
{opt ? formatTimezone(opt) : z}
</span>
<button
onClick={() => remove(z)}
title={de ? "Entfernen" : "Remove"}
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
<div style={{ display: "flex", gap: "6px" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<SearchableDropdown
value={picker}
onChange={(v) => setPicker(v)}
searchable
placeholder={de ? "Zeitzone auswählen…" : "Select a timezone…"}
searchPlaceholder={de ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
emptyText={de ? "Keine Treffer" : "No matches"}
options={TIMEZONE_OPTIONS
.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone)
.sort((a, b) => {
const oa = getOffsetMinutes(a.zone);
const ob = getOffsetMinutes(b.zone);
if (oa !== ob) return oa - ob;
return a.code.localeCompare(b.code);
})
.map((opt) => {
const off = formatOffset(getOffsetMinutes(opt.zone));
return {
value: opt.zone,
label: `${opt.code}${opt.label}`,
secondary: `UTC${off}`,
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
};
})}
/>
</div>
<button
onClick={() => add(picker)}
disabled={!picker}
className="weekly-btn-primary"
style={{ padding: "6px 10px", fontSize: "0.85rem", display: "inline-flex", alignItems: "center", gap: "4px", opacity: picker ? 1 : 0.5 }}
>
<Plus size={14} /> {de ? "Hinzufügen" : "Add"}
</button>
</div>
</div>
);
}
// All customizable color custom-properties defined in globals.css :root, grouped for the
// "Styling" tab's CSS Variables editor. Overrides are stored as a flat { "--var-name": "#hex" }
// map in profile.customCssVars and applied on top of containerStyle in WeeklyView. Entries with
// a `profileKey` instead read/write that scalar profile field directly (same fields the
// "Element Colors" / "Weekend Highlight Colors" controls elsewhere in this tab use), so both
// controls for the same setting always stay in sync.
const CSS_VARIABLE_GROUPS: { title: string; vars: { name: string; label: string; default: string; profileKey?: string }[] }[] = [
{
title: "Element Colors",
vars: [
{ name: "todayHighlightColor", label: "Today Highlight", default: "#f0fafa", profileKey: "todayHighlightColor" },
{ name: "pastDayColor", label: "Past Days", default: "#a6a6a7", profileKey: "pastDayColor" },
],
},
{
title: "Weekend Highlight Colors",
vars: [
{ name: "weekendColorSat", label: "Saturday", default: "#666666", profileKey: "weekendColorSat" },
{ name: "weekendColorSun", label: "Sunday", default: "#dc2626", profileKey: "weekendColorSun" },
],
},
{
title: "Base Colors",
vars: [
{ name: "--weekly-teal", label: "Accent", default: "#009a9a" },
{ name: "--weekly-bg", label: "Background", default: "#ffffff" },
{ name: "--weekly-text", label: "Text", default: "#000000" },
{ name: "--weekly-text-light", label: "Text (light)", default: "#767676" },
{ name: "--weekly-border", label: "Border", default: "#cccccc" },
{ name: "--weekly-completed", label: "Completed task", default: "#cccccc" },
],
},
{
title: "Settings Sidebar",
vars: [
{ name: "--weekly-settings-bg", label: "Background", default: "#ffffff" },
{ name: "--weekly-settings-item-bg", label: "Item background", default: "#f8fafc" },
{ name: "--weekly-settings-label", label: "Label", default: "#475569" },
{ name: "--weekly-settings-title", label: "Title", default: "#111827" },
{ name: "--weekly-settings-input-bg", label: "Input background", default: "#ffffff" },
{ name: "--weekly-settings-input-border", label: "Input border", default: "#dddddd" },
{ name: "--weekly-settings-text", label: "Text", default: "#111827" },
{ name: "--weekly-settings-toggle-bg", label: "Toggle background", default: "#f3f4f6" },
{ name: "--weekly-settings-toggle-active-bg", label: "Toggle active background", default: "#ffffff" },
{ name: "--weekly-settings-toggle-text", label: "Toggle text", default: "#6b7280" },
{ name: "--weekly-settings-toggle-active-text", label: "Toggle active text", default: "#111827" },
],
},
{
title: "Design Token Aliases",
vars: [
{ name: "--bg", label: "Background (alias)", default: "#ffffff" },
{ name: "--paper", label: "Paper (alias)", default: "#ffffff" },
{ name: "--ink", label: "Text (alias)", default: "#000000" },
{ name: "--ink-2", label: "Text variant 2", default: "#2a2a28" },
{ name: "--ink-3", label: "Text light (alias)", default: "#767676" },
{ name: "--ink-4", label: "Text variant 4", default: "#a8a8a2" },
{ name: "--ink-5", label: "Text variant 5", default: "#c8c8c2" },
{ name: "--accent", label: "Accent (alias)", default: "#009a9a" },
{ name: "--line", label: "Border (alias)", default: "#cccccc" },
{ name: "--line-soft", label: "Border (soft)", default: "#f3f3f0" },
{ name: "--weekend", label: "Weekend", default: "#dc2626" },
{ name: "--tasks-bg", label: "Tasks background", default: "#f4f2ee" },
],
},
{
title: "Event Colors — Default",
vars: [
{ name: "--ev-default-bg", label: "Background", default: "#eef2f7" },
{ name: "--ev-default-border", label: "Border", default: "#6c87a8" },
{ name: "--ev-default-title", label: "Title", default: "#2b3f57" },
{ name: "--ev-default-meta", label: "Meta", default: "#6c87a8" },
],
},
{
title: "Event Colors — Family",
vars: [
{ name: "--ev-fam-bg", label: "Background", default: "#fbeef0" },
{ name: "--ev-fam-border", label: "Border", default: "#b85a6a" },
{ name: "--ev-fam-title", label: "Title", default: "#5a2530" },
{ name: "--ev-fam-meta", label: "Meta", default: "#99536a" },
],
},
{
title: "Event Colors — Finance",
vars: [
{ name: "--ev-fin-bg", label: "Background", default: "#f6f0e2" },
{ name: "--ev-fin-border", label: "Border", default: "#a88a3c" },
{ name: "--ev-fin-title", label: "Title", default: "#4a3a14" },
{ name: "--ev-fin-meta", label: "Meta", default: "#8a6f2a" },
],
},
{
title: "Event Colors — Development",
vars: [
{ name: "--ev-dev-bg", label: "Background", default: "#ecf3ed" },
{ name: "--ev-dev-border", label: "Border", default: "#5a7a4a" },
{ name: "--ev-dev-title", label: "Title", default: "#2c4527" },
{ name: "--ev-dev-meta", label: "Meta", default: "#5a7a4a" },
],
},
{
title: "All-Day Chips",
vars: [
{ name: "--chip-default-bg", label: "Default", default: "#b8b8b0" },
{ name: "--chip-fam-bg", label: "Family", default: "#e88a8a" },
{ name: "--chip-special-bg", label: "Special background", default: "#f4d588" },
{ name: "--chip-special-text", label: "Special text", default: "#6b4f10" },
],
},
];
// Lets the user override any globals.css :root color variable individually. Overrides are
// merged into profile.customCssVars (a flat map) and spread onto the root container's inline
// style in WeeklyView, so they take precedence over every other color source (theme, per-field
// settings, etc.).
function CssVariablesEditor({
profile,
setProfile,
saveSetting,
}: {
profile: any;
setProfile: React.Dispatch<React.SetStateAction<any>>;
saveSetting: (key: string, value: any) => void;
}) {
const de = profile.language === "de";
const vars: Record<string, string> = profile.customCssVars || {};
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const fieldDebounceTimers = useRef<Record<string, NodeJS.Timeout>>({});
const setVar = (name: string, value: string) => {
const next = { ...(profile.customCssVars || {}), [name]: value };
setProfile((p: any) => ({ ...p, customCssVars: next }));
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => saveSetting("customCssVars", next), 500);
};
const setProfileField = (key: string, value: string) => {
setProfile((p: any) => ({ ...p, [key]: value }));
if (fieldDebounceTimers.current[key]) clearTimeout(fieldDebounceTimers.current[key]);
fieldDebounceTimers.current[key] = setTimeout(() => saveSetting(key, value), 500);
};
const reset = () => {
setProfile((p: any) => ({ ...p, customCssVars: {} }));
saveSetting("customCssVars", {});
};
return (
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
{de ? "CSS-Variablen" : "CSS Variables"}
</label>
<button
type="button"
onClick={reset}
style={{
fontSize: "0.7rem",
background: "none",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "6px",
padding: "3px 8px",
cursor: "pointer",
color: "var(--weekly-settings-label)",
}}
>
{de ? "Zurücksetzen" : "Reset"}
</button>
</div>
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 10px", opacity: 0.8 }}>
{de
? "Passe jede Design-Farbvariable des Stylesheets einzeln an."
: "Fine-tune every design-token color used by the stylesheet."}
</p>
{CSS_VARIABLE_GROUPS.map((group) => (
<div key={group.title} style={{ marginBottom: "12px" }}>
<div
style={{
fontSize: "0.7rem",
fontWeight: 600,
color: "var(--weekly-settings-title)",
marginBottom: "6px",
textTransform: "uppercase",
letterSpacing: "0.02em",
}}
>
{group.title}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 12px" }}>
{group.vars.map((v) => {
const value = v.profileKey ? (profile[v.profileKey] || v.default) : (vars[v.name] || v.default);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) =>
v.profileKey ? setProfileField(v.profileKey, e.target.value) : setVar(v.name, e.target.value);
return (
<div
key={v.name}
title={v.name}
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="color"
value={value}
onChange={onChange}
style={{
width: "32px",
height: "22px",
cursor: "pointer",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "transparent",
flexShrink: 0,
}}
/>
<span
style={{
fontSize: "0.72rem",
color: "var(--weekly-settings-label)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{v.label}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// Minimal ProjectIcon — resolves an icon name from the unified registry.
function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) {
@ -1193,11 +842,11 @@ function SettingsSidebar({
className="weekly-settings-tabs"
style={{
display: "flex",
justifyContent: "space-around",
flexWrap: "nowrap",
gap: "0",
justifyContent: "center",
flexWrap: "wrap",
gap: "4px",
borderBottom: "1px solid var(--weekly-border, #eee)",
padding: "0",
padding: "0 24px",
}}
>
{([
@ -1205,7 +854,7 @@ function SettingsSidebar({
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
{ key: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" },
{ key: "projects", icon: <FolderOpen size={18} />, label: t.projects || "Projects" },
{ key: "projects", icon: <Briefcase size={18} />, label: t.projects || "Projects" },
{ key: "account", icon: <User size={18} />, label: t.account },
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
@ -1217,7 +866,7 @@ function SettingsSidebar({
title={tab.label}
className="settings-tab-btn"
style={{
padding: "10px 8px",
padding: "10px 14px",
borderBottom:
activeTab === tab.key
? "2px solid var(--weekly-text, black)"
@ -1255,74 +904,6 @@ function SettingsSidebar({
>
{/* ── General settings (not view-specific) ── */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingBottom: "16px", borderBottom: "1px solid var(--weekly-border, #e5e7eb)" }}>
{/* Menu Position */}
<div>
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
{profile.language === "de" ? "Menüposition" : "Menu Position"}
</label>
<div className="mt-1 flex gap-2">
{[
{ value: "left", label: profile.language === "de" ? "Links (Seitenleiste)" : "Left (Sidebar)" },
{ value: "top", label: profile.language === "de" ? "Oben (Kopfleiste)" : "Top (Header)" },
].map(({ value, label }) => (
<button
key={value}
onClick={() => {
setProfile((p: any) => ({ ...p, menuPosition: value }));
saveSetting("menuPosition", value);
}}
style={{
flex: 1,
padding: "6px 10px",
fontSize: "0.8rem",
borderRadius: "6px",
border: "1px solid var(--weekly-border, #e5e7eb)",
cursor: "pointer",
fontWeight: (profile.menuPosition || "left") === value ? 700 : 400,
background: (profile.menuPosition || "left") === value ? "var(--weekly-text, #333)" : "transparent",
color: (profile.menuPosition || "left") === value ? "#fff" : "var(--weekly-settings-label)",
}}
>
{label}
</button>
))}
</div>
<div style={{ marginTop: "10px" }}>
<label style={{ fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
{profile.language === "de" ? "Steuerung in der Kopfleiste" : "Header Controls"}
</label>
<div className="mt-1 flex gap-2">
{[
{ value: true, label: profile.language === "de" ? "Anzeigen" : "Show" },
{ value: false, label: profile.language === "de" ? "Ausblenden" : "Hide" },
].map(({ value, label }) => {
const isActive = (profile.showHeaderControls !== false) === value;
return (
<button
key={String(value)}
onClick={() => {
setProfile((p: any) => ({ ...p, showHeaderControls: value }));
saveSetting("showHeaderControls", value);
}}
style={{
flex: 1,
padding: "6px 10px",
fontSize: "0.8rem",
borderRadius: "6px",
border: "1px solid var(--weekly-border, #e5e7eb)",
cursor: "pointer",
fontWeight: isActive ? 700 : 400,
background: isActive ? "var(--weekly-text, #333)" : "transparent",
color: isActive ? "#fff" : "var(--weekly-settings-label)",
}}
>
{label}
</button>
);
})}
</div>
</div>
</div>
{/* Header Display */}
<div>
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
@ -2017,17 +1598,23 @@ function SettingsSidebar({
>
{t.language}
</label>
<SearchableDropdown
<select
value={profile.language || "de"}
onChange={(v) => saveField("language", v)}
options={[
{ value: "en", label: "English", leading: <FlagIcon code="en" width={20} height={14} /> },
{ value: "de", label: "Deutsch", leading: <FlagIcon code="de" width={20} height={14} /> },
{ value: "fr", label: "Français", leading: <FlagIcon code="fr" width={20} height={14} /> },
{ value: "es", label: "Español", leading: <FlagIcon code="es" width={20} height={14} /> },
{ value: "it", label: "Italiano", leading: <FlagIcon code="it" width={20} height={14} /> },
]}
/>
onChange={(e) => saveField("language", e.target.value)}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
<option value="es">🇪🇸 Español</option>
<option value="it">🇮🇹 Italiano</option>
</select>
</div>
<div>
@ -2041,37 +1628,19 @@ function SettingsSidebar({
>
{t.timezone}
</label>
<SearchableDropdown
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
onChange={(v) => saveField("timezone", v)}
searchable
searchPlaceholder={profile.language === "de" ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
emptyText={profile.language === "de" ? "Keine Treffer" : "No matches"}
options={[...TIMEZONE_OPTIONS]
.sort((a, b) => {
const oa = getOffsetMinutes(a.zone);
const ob = getOffsetMinutes(b.zone);
if (oa !== ob) return oa - ob;
return a.code.localeCompare(b.code);
})
.map((opt) => {
const off = formatOffset(getOffsetMinutes(opt.zone));
return {
value: opt.zone,
label: `${opt.code}${opt.label}`,
secondary: `UTC${off}`,
// Match against IANA name (zone), code, label,
// and the offset in several styles.
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
};
})}
/>
{/* Multi-timezone (Punkt 6) */}
<ExtraTimezonesEditor
profile={profile}
setProfile={setProfile}
saveSetting={saveSetting}
/>
<div
style={{
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)",
opacity: 0.8,
}}
>
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</div>
</div>
<div>
@ -3954,55 +3523,6 @@ function SettingsSidebar({
</div>
</div>
{/* CSS Variables */}
<CssVariablesEditor profile={profile} setProfile={setProfile} saveSetting={saveSetting} />
{/* Custom CSS */}
<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: "4px",
}}
>
{profile.language === "de" ? "Eigenes CSS" : "Custom CSS"}
</label>
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 8px", opacity: 0.8 }}>
{profile.language === "de"
? "Wird nach dem Stylesheet geladen und kann jede Regel überschreiben."
: "Loaded after the stylesheet — can override any rule."}
</p>
<textarea
value={profile.customCss || ""}
onChange={(e) => saveFieldDebounced("customCss", e.target.value)}
rows={10}
spellCheck={false}
placeholder={".weekly-task-item.completed .weekly-task-text {\n color: #cccccc;\n}"}
style={{
width: "100%",
fontFamily: "monospace",
fontSize: "0.75rem",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "6px",
padding: "8px",
resize: "vertical",
boxSizing: "border-box",
}}
/>
</div>
{/* All styling settings auto-save */}
</div>
) : activeTab === "motivation" ? (

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ export const authOptions: NextAuthOptions = {
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: false,
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
prompt: "consent",
@ -26,13 +26,13 @@ export const authOptions: NextAuthOptions = {
AppleProvider({
clientId: process.env.APPLE_ID || "",
clientSecret: process.env.APPLE_SECRET || "",
allowDangerousEmailAccountLinking: false,
allowDangerousEmailAccountLinking: true,
}),
AzureADProvider({
clientId: process.env.MICROSOFT_CLIENT_ID || "",
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
tenantId: "common",
allowDangerousEmailAccountLinking: false,
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
prompt: "consent",
@ -85,7 +85,7 @@ export const authOptions: NextAuthOptions = {
}
})
],
debug: process.env.NODE_ENV === 'development',
debug: true,
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days

View File

@ -78,7 +78,6 @@ export async function readCachedEvents(
calendarId: row.calendarId,
calendarTitle: row.calendarTitle,
calendarColor: row.calendarColor,
busyStatus: row.busyStatus ?? undefined,
}));
}
@ -129,7 +128,6 @@ export async function refreshConnectionCache(
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
endDate: ev.end.date ?? null,
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
busyStatus: ev.busyStatus ?? null,
weekStart,
syncedAt: now,
}));
@ -185,7 +183,6 @@ export async function upsertCachedEvent(
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
busyStatus: event.busyStatus ?? null,
weekStart,
syncedAt: new Date(),
},
@ -203,7 +200,6 @@ export async function upsertCachedEvent(
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
busyStatus: event.busyStatus ?? null,
weekStart,
syncedAt: new Date(),
},
@ -218,22 +214,7 @@ export async function deleteCachedEvent(
externalId: string,
provider: string,
): Promise<void> {
// For Outlook recurring events, our composite ID is "seriesMasterId::instanceId".
// When the user deletes the whole series, every cached occurrence of that series
// must be cleared too — otherwise the surviving rows reappear after the next read.
const seriesMasterId = externalId.includes('::') ? externalId.split('::')[0] : externalId;
await prisma.cachedCalendarEvent.deleteMany({
where: {
userId,
provider,
OR: [
{ externalId },
{ externalId: seriesMasterId },
{ recurringEventId: seriesMasterId },
// The composite IDs of all instances start with "<seriesMasterId>::"
{ externalId: { startsWith: `${seriesMasterId}::` } },
],
},
where: { userId, externalId, provider },
});
}

View File

@ -863,13 +863,8 @@ export const createCalendarEvent = async (
source: 'outlook',
calendarId,
calendarTitle: '',
isRecurring: createdEvent.isRecurring ?? !!event.recurrence,
recurringEventId: createdEvent.recurringEventId ?? (event.recurrence ? createdEvent.id : undefined),
reminders: createdEvent.reminders,
busyStatus: createdEvent.busyStatus as BusyStatus | undefined,
visibility: createdEvent.visibility as EventVisibility | undefined,
attendees: createdEvent.attendees as EventAttendee[] | undefined,
url: createdEvent.htmlLink,
isRecurring: !!event.recurrence,
recurringEventId: event.recurrence ? createdEvent.id : undefined,
} as CalendarEvent;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
@ -1119,13 +1114,6 @@ export const updateCalendarEvent = async (
source: 'outlook',
calendarId,
calendarTitle: '',
isRecurring: updatedEvent.isRecurring,
recurringEventId: updatedEvent.recurringEventId,
reminders: updatedEvent.reminders,
busyStatus: updatedEvent.busyStatus as BusyStatus | undefined,
visibility: updatedEvent.visibility as EventVisibility | undefined,
attendees: updatedEvent.attendees as EventAttendee[] | undefined,
url: updatedEvent.htmlLink,
} as CalendarEvent;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
@ -1354,16 +1342,6 @@ export const deleteCalendarEvent = async (
} else if (deleteMode === 'all' || !hasInstanceId) {
// Delete the entire series (use series master ID)
await deleteOutlookEvent(accessToken, calendarId, seriesMasterId);
// Safety net: Outlook sometimes leaves the first occurrence as an orphan
// after deleting the seriesMaster (especially when the master's start
// matches the first occurrence). Explicitly delete the instance ID too.
if (hasInstanceId && instanceId && instanceId !== seriesMasterId) {
try {
await deleteOutlookEvent(accessToken, calendarId, instanceId);
} catch (e) {
// Ignore — the master delete is the authoritative operation.
}
}
} else {
// 'future' or 'past' — Outlook doesn't support partial series delete easily
// Fall back to deleting the series

View File

@ -1,5 +1,4 @@
import nodemailer from 'nodemailer';
import { getVerificationCopy, getResetCopy } from './emailTemplates';
let _transporter: nodemailer.Transporter | null = null;
@ -27,16 +26,10 @@ function getTransporter() {
export async function sendVerificationEmail(
email: string,
code: string,
token: string,
language?: string | null,
token: string
) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
// Point the button at the client verify page (not the API GET handler).
// This stops corporate email scanners (M365 Safe Links, etc.) from
// pre-fetching the URL and silently consuming the one-shot token before
// the user ever sees the email — which is the bug behind point #2.
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`;
const t = getVerificationCopy(language);
const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
const html = `
<!DOCTYPE html>
@ -50,32 +43,59 @@ export async function sendVerificationEmail(
<tr>
<td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
<!-- Header -->
<tr>
<td style="padding: 32px 40px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
My Weekly ToDo's
</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 16px 40px;">
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.welcome}</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
Welcome! Please verify your email address to complete your registration.
</p>
<!-- Code Box -->
<div style="background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px; padding: 24px; text-align: center; margin: 0 0 24px;">
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">${t.yourCode}</p>
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">${code}</p>
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">
Your verification code
</p>
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">
${code}
</p>
</div>
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">${t.orClick}</p>
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">
Or click the button below to verify instantly:
</p>
<!-- Button -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 0 0 24px;">
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
Verify Email
</a>
</td>
</tr>
</table>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
Simple. Beautiful. Yours.
</p>
</td>
</tr>
</table>
@ -88,22 +108,23 @@ export async function sendVerificationEmail(
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
console.log(`[EMAIL] Sending verification email to ${email} from ${from}`);
if (process.env.NODE_ENV === 'development') {
console.log(`\n======================================================`);
console.log(`[DEV VERIFICATION LINK]:\n${verifyLink}`);
console.log(`======================================================\n`);
}
try {
// 15 second timeout for SMTP
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
);
const mailPromise = getTransporter().sendMail({
from,
to: email,
subject: t.subject(code),
subject: `${code} Verify your email for My Weekly ToDo's`,
html,
});
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
} catch (err: any) {
@ -111,10 +132,9 @@ export async function sendVerificationEmail(
}
}
export async function sendPasswordResetEmail(email: string, token: string, language?: string | null) {
export async function sendPasswordResetEmail(email: string, token: string) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const resetLink = `${baseUrl}/auth/reset-password?token=${token}`;
const t = getResetCopy(language);
const html = `
<!DOCTYPE html>
@ -130,25 +150,35 @@ export async function sendPasswordResetEmail(email: string, token: string, langu
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 32px 40px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
My Weekly ToDo's
</h1>
</td>
</tr>
<tr>
<td style="padding: 16px 40px;">
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.intro}</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
We received a request to reset your password. Click the button below to choose a new one.
</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 0 0 24px;">
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
Reset Password
</a>
</td>
</tr>
</table>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
Simple. Beautiful. Yours.
</p>
</td>
</tr>
</table>
@ -161,22 +191,22 @@ export async function sendPasswordResetEmail(email: string, token: string, langu
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
console.log(`[EMAIL] Sending password reset email to ${email} from ${from}`);
if (process.env.NODE_ENV === 'development') {
console.log(`\n======================================================`);
console.log(`[DEV RESET LINK]:\n${resetLink}`);
console.log(`======================================================\n`);
}
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
);
const mailPromise = getTransporter().sendMail({
from,
to: email,
subject: t.subject,
subject: `Password Reset My Weekly ToDo's`,
html,
});
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`);
} catch (err: any) {

View File

@ -1,135 +0,0 @@
// Localised email copy. Keep keys stable across languages so the template
// builders can swap them by language code without dynamic imports.
type Lang = "en" | "de" | "fr" | "es" | "it";
interface VerificationCopy {
subject: (code: string) => string;
welcome: string;
yourCode: string;
orClick: string;
button: string;
expiresNote: string;
footer: string;
}
interface ResetCopy {
subject: string;
intro: string;
button: string;
expiresNote: string;
footer: string;
}
const VERIFICATION: Record<Lang, VerificationCopy> = {
en: {
subject: (c) => `${c} Verify your email for My Weekly ToDo's`,
welcome: "Welcome! Please verify your email address to complete your registration.",
yourCode: "Your verification code",
orClick: "Or click the button below to verify instantly:",
button: "✓ Verify Email",
expiresNote: "This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.",
footer: "Simple. Beautiful. Yours.",
},
de: {
subject: (c) => `${c} Bestätige deine E-Mail-Adresse für My Weekly ToDo's`,
welcome: "Willkommen! Bitte bestätige deine E-Mail-Adresse, um deine Registrierung abzuschließen.",
yourCode: "Dein Bestätigungscode",
orClick: "Oder klicke unten, um sofort zu bestätigen:",
button: "✓ E-Mail bestätigen",
expiresNote: "Dieser Code läuft in 15 Minuten ab. Falls du diesen Account nicht erstellt hast, kannst du diese E-Mail einfach ignorieren.",
footer: "Einfach. Schön. Deins.",
},
fr: {
subject: (c) => `${c} Vérifiez votre adresse e-mail pour My Weekly ToDo's`,
welcome: "Bienvenue ! Veuillez vérifier votre adresse e-mail pour terminer votre inscription.",
yourCode: "Votre code de vérification",
orClick: "Ou cliquez sur le bouton ci-dessous pour vérifier instantanément :",
button: "✓ Vérifier l'e-mail",
expiresNote: "Ce code expire dans 15 minutes. Si vous n'avez pas créé de compte, vous pouvez ignorer cet e-mail.",
footer: "Simple. Beau. À vous.",
},
es: {
subject: (c) => `${c} Verifica tu correo para My Weekly ToDo's`,
welcome: "¡Bienvenido! Por favor verifica tu dirección de correo para completar tu registro.",
yourCode: "Tu código de verificación",
orClick: "O haz clic en el botón de abajo para verificar al instante:",
button: "✓ Verificar correo",
expiresNote: "Este código expira en 15 minutos. Si no creaste una cuenta, puedes ignorar este correo.",
footer: "Simple. Hermoso. Tuyo.",
},
it: {
subject: (c) => `${c} Verifica la tua email per My Weekly ToDo's`,
welcome: "Benvenuto! Verifica il tuo indirizzo email per completare la registrazione.",
yourCode: "Il tuo codice di verifica",
orClick: "Oppure clicca sul pulsante qui sotto per verificare subito:",
button: "✓ Verifica email",
expiresNote: "Questo codice scade tra 15 minuti. Se non hai creato un account, puoi ignorare questa email.",
footer: "Semplice. Bello. Tuo.",
},
};
const RESET: Record<Lang, ResetCopy> = {
en: {
subject: "Password Reset My Weekly ToDo's",
intro: "We received a request to reset your password. Click the button below to choose a new one.",
button: "Reset Password",
expiresNote: "This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.",
footer: "Simple. Beautiful. Yours.",
},
de: {
subject: "Passwort zurücksetzen My Weekly ToDo's",
intro: "Wir haben eine Anfrage zum Zurücksetzen deines Passworts erhalten. Klicke auf den Button unten, um ein neues Passwort zu wählen.",
button: "Passwort zurücksetzen",
expiresNote: "Dieser Link läuft in 1 Stunde ab. Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail einfach ignorieren.",
footer: "Einfach. Schön. Deins.",
},
fr: {
subject: "Réinitialisation du mot de passe My Weekly ToDo's",
intro: "Nous avons reçu une demande de réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.",
button: "Réinitialiser le mot de passe",
expiresNote: "Ce lien expire dans 1 heure. Si vous n'avez pas demandé de réinitialisation, vous pouvez ignorer cet e-mail.",
footer: "Simple. Beau. À vous.",
},
es: {
subject: "Restablecimiento de contraseña My Weekly ToDo's",
intro: "Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para elegir una nueva.",
button: "Restablecer contraseña",
expiresNote: "Este enlace expira en 1 hora. Si no solicitaste un restablecimiento, puedes ignorar este correo.",
footer: "Simple. Hermoso. Tuyo.",
},
it: {
subject: "Reimpostazione password My Weekly ToDo's",
intro: "Abbiamo ricevuto una richiesta di reimpostazione della password. Clicca sul pulsante qui sotto per sceglierne una nuova.",
button: "Reimposta password",
expiresNote: "Questo link scade tra 1 ora. Se non hai richiesto la reimpostazione, puoi ignorare questa email.",
footer: "Semplice. Bello. Tuo.",
},
};
function pickLang(input?: string | null): Lang {
if (!input) return "en";
const code = input.toLowerCase().slice(0, 2);
if (code === "de" || code === "fr" || code === "es" || code === "it" || code === "en") return code;
return "en";
}
// Parse the first acceptable locale out of a raw Accept-Language header so we
// can pre-fill a user's UI language at signup.
export function languageFromAcceptLanguage(header?: string | null): Lang {
if (!header) return "en";
const tags = header.split(",").map((t) => t.trim().split(";")[0]);
for (const t of tags) {
const code = t.toLowerCase().slice(0, 2);
if (code === "de" || code === "fr" || code === "es" || code === "it" || code === "en") return code;
}
return "en";
}
export function getVerificationCopy(language?: string | null): VerificationCopy {
return VERIFICATION[pickLang(language)];
}
export function getResetCopy(language?: string | null): ResetCopy {
return RESET[pickLang(language)];
}

View File

@ -147,22 +147,6 @@ export const deleteGoogleTask = async (client: OAuth2Client, taskListId: string,
}
};
/**
* Delete a Google Tasks list.
* Returns silently on 404 (already deleted).
*/
export const deleteGoogleTaskList = async (client: OAuth2Client, taskListId: string): Promise<void> => {
const service = google.tasks({ version: 'v1', auth: client });
try {
await service.tasklists.delete({ tasklist: taskListId });
} catch (error: any) {
const status = error?.code || error?.response?.status;
if (status === 404 || status === 410) return;
console.error(`Error deleting Google Task list ${taskListId}:`, error);
throw error;
}
};
/**
* Fetch tasks from a specific list including completed ones (for sync)
*/

View File

@ -87,32 +87,6 @@ export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTo
return (data.value || []) as MicrosoftTodoList[];
};
/**
* Delete a Microsoft To-Do task list.
*/
export const deleteMsTodoList = async (
accessToken: string,
listId: string
): Promise<void> => {
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`
}
}
);
// 404/410 mean it's already gone — treat as success
if (response.status === 404 || response.status === 410) return;
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to delete To-Do list: ${err}`);
}
};
/**
* Fetch active tasks from a specific To-Do list (for import).
*/
@ -184,78 +158,6 @@ export const fetchMsTodoTasksForSync = async (
return allTasks;
};
/**
* Microsoft Graph delta sync: returns only the tasks that changed (created,
* updated, or deleted) since the previous delta token, plus a fresh
* `deltaToken` to use on the next call. Pass `null` to start a new chain.
*
* Deleted tasks come back with an `@removed` field; we surface them via
* the optional `_deleted` boolean on the task object.
*
* Cost: typically a few hundred bytes per call when nothing changed.
*/
export const fetchMsTodoTasksDelta = async (
accessToken: string,
listId: string,
previousDeltaToken: string | null,
): Promise<{ tasks: (MicrosoftTodoTask & { _deleted?: boolean })[]; deltaToken: string | null }> => {
const tasks: (MicrosoftTodoTask & { _deleted?: boolean })[] = [];
let url: string | null;
if (previousDeltaToken) {
// Resume from where we left off
url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta?$deltatoken=${encodeURIComponent(previousDeltaToken)}`;
} else {
// Initial sync — Graph will paginate via @odata.nextLink, then return @odata.deltaLink
url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta`;
}
let deltaToken: string | null = null;
while (url) {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const err = await response.text();
// 410 Gone = delta token expired (>30 days). Caller should retry with null.
if (response.status === 410) {
throw Object.assign(new Error('Delta token expired'), { code: 'DELTA_EXPIRED' });
}
console.error(`Error fetching delta from list ${listId}:`, err);
throw new Error(`Failed to fetch To-Do delta: ${response.status} ${response.statusText}`);
}
const data = await response.json();
for (const item of (data.value || [])) {
if (item['@removed']) {
tasks.push({ ...item, _deleted: true } as any);
} else {
tasks.push(item as MicrosoftTodoTask);
}
}
const nextLink: string | undefined = data['@odata.nextLink'];
const deltaLink: string | undefined = data['@odata.deltaLink'];
if (deltaLink) {
// Final page — extract the new delta token
const match = deltaLink.match(/[?&]\$deltatoken=([^&]+)/);
deltaToken = match ? decodeURIComponent(match[1]) : null;
url = null;
} else if (nextLink) {
url = nextLink;
} else {
url = null;
}
}
return { tasks, deltaToken };
};
/**
* Create a new Microsoft To-Do task in a list.
*/
@ -266,7 +168,6 @@ export const createMsTodoTask = async (
title: string;
body?: string;
dueDateTime?: string;
importance?: 'low' | 'normal' | 'high';
}
): Promise<MicrosoftTodoTask> => {
const requestBody: any = { title: taskData.title };
@ -280,9 +181,6 @@ export const createMsTodoTask = async (
timeZone: 'UTC'
};
}
if (taskData.importance) {
requestBody.importance = taskData.importance;
}
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`,
@ -316,7 +214,6 @@ export const updateMsTodoTask = async (
body?: string;
status?: 'notStarted' | 'completed';
dueDateTime?: string | null;
importance?: 'low' | 'normal' | 'high';
}
): Promise<MicrosoftTodoTask> => {
const body: any = {};
@ -339,7 +236,6 @@ export const updateMsTodoTask = async (
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
: null;
}
if (updates.importance !== undefined) body.importance = updates.importance;
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,

View File

@ -201,7 +201,54 @@ export const getUpcomingEvents = async (
}
const data = await response.json();
return data.value.map((event: any) => mapOutlookEventResponse(event));
return data.value.map((event: any) => {
// Map Outlook showAs to our busyStatus
const showAsMap: Record<string, string> = {
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
};
// Map Outlook sensitivity to our visibility
const sensitivityMap: Record<string, string> = {
'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential',
};
// Outlook returns dateTime without Z suffix even when timeZone is UTC.
// Append Z so JS Date parsing treats it as UTC (not local time).
const fixUtc = (dt: string, tz: string) =>
dt && tz === 'UTC' && !dt.endsWith('Z') ? dt + 'Z' : dt;
return {
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
summary: event.subject,
description: event.body?.content || event.bodyPreview,
start: {
dateTime: fixUtc(event.start.dateTime, event.start.timeZone),
timeZone: event.start.timeZone
},
end: {
dateTime: fixUtc(event.end.dateTime, event.end.timeZone),
timeZone: event.end.timeZone
},
location: event.location?.displayName,
htmlLink: event.webLink,
allDay: event.isAllDay,
recurringEventId: event.seriesMasterId || undefined,
isRecurring: event.type === 'occurrence' || event.type === 'exception' || event.type === 'seriesMaster',
reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null
? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }]
: undefined,
busyStatus: showAsMap[event.showAs] || undefined,
visibility: sensitivityMap[event.sensitivity] || undefined,
attendees: event.attendees?.map((a: any) => ({
email: a.emailAddress?.address,
displayName: a.emailAddress?.name,
responseStatus: a.status?.response === 'accepted' ? 'accepted'
: a.status?.response === 'declined' ? 'declined'
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
: 'needsAction',
})),
};
});
};
const ensureTimeZone = (dateTimeObj: any) => {
@ -236,45 +283,6 @@ const normalizeOutlookDateTime = (dtObj: any) => {
return { dateTime: dt, timeZone: dtObj.timeZone };
};
// Map a raw Microsoft Graph event response into our internal shape.
// Used after create/update to keep the full set of fields (busyStatus, visibility,
// attendees, reminders, recurrence info) flowing back to the cache + UI.
const mapOutlookEventResponse = (ev: any) => {
const showAsMap: Record<string, string> = {
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
};
const sensitivityMap: Record<string, string> = {
'normal': 'default', 'personal': 'default',
'private': 'private', 'confidential': 'confidential',
};
return {
id: ev.seriesMasterId ? `${ev.seriesMasterId}::${ev.id}` : ev.id,
summary: ev.subject,
description: ev.body?.content || ev.bodyPreview,
start: normalizeOutlookDateTime(ev.start),
end: normalizeOutlookDateTime(ev.end),
location: ev.location?.displayName,
htmlLink: ev.webLink,
allDay: ev.isAllDay,
recurringEventId: ev.seriesMasterId || undefined,
isRecurring: ev.type === 'occurrence' || ev.type === 'exception' || ev.type === 'seriesMaster',
reminders: ev.isReminderOn && ev.reminderMinutesBeforeStart != null
? [{ method: 'popup', minutes: ev.reminderMinutesBeforeStart }]
: undefined,
busyStatus: showAsMap[ev.showAs] || undefined,
visibility: sensitivityMap[ev.sensitivity] || undefined,
attendees: ev.attendees?.map((a: any) => ({
email: a.emailAddress?.address,
displayName: a.emailAddress?.name,
responseStatus: a.status?.response === 'accepted' ? 'accepted'
: a.status?.response === 'declined' ? 'declined'
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
: 'needsAction',
})),
};
};
export const createEvent = async (
accessToken: string,
calendarId: string,
@ -328,7 +336,15 @@ export const createEvent = async (
}
const created = await response.json();
return mapOutlookEventResponse(created);
return {
id: created.id,
summary: created.subject,
description: created.bodyPreview,
start: normalizeOutlookDateTime(created.start),
end: normalizeOutlookDateTime(created.end),
location: created.location?.displayName,
allDay: created.isAllDay
};
};
/**
@ -403,7 +419,15 @@ export const updateEvent = async (
}
const updated = await response.json();
return mapOutlookEventResponse(updated);
return {
id: updated.id,
summary: updated.subject,
description: updated.bodyPreview,
start: normalizeOutlookDateTime(updated.start),
end: normalizeOutlookDateTime(updated.end),
location: updated.location?.displayName,
allDay: updated.isAllDay
};
};
/**

View File

@ -1,97 +0,0 @@
// Curated list of common IANA timezones with widely recognised abbreviations.
// `code` is the abbreviation users expect to see (CET, ET, JST...). The IANA
// name (`zone`) is what we actually persist and pass to Intl APIs.
export interface TimezoneOption {
zone: string; // IANA name, e.g. "Europe/Berlin"
code: string; // colloquial abbreviation, e.g. "CET"
label: string; // human-friendly city/region name
}
export const TIMEZONE_OPTIONS: TimezoneOption[] = [
{ zone: "Pacific/Midway", code: "SST", label: "Samoa" },
{ zone: "Pacific/Honolulu", code: "HST", label: "Hawaii" },
{ zone: "America/Anchorage", code: "AKT", label: "Alaska" },
{ zone: "America/Los_Angeles", code: "PT", label: "Los Angeles, San Francisco" },
{ zone: "America/Denver", code: "MT", label: "Denver, Phoenix" },
{ zone: "America/Chicago", code: "CT", label: "Chicago, Mexico City" },
{ zone: "America/New_York", code: "ET", label: "New York, Toronto" },
{ zone: "America/Halifax", code: "AT", label: "Halifax" },
{ zone: "America/Sao_Paulo", code: "BRT", label: "São Paulo" },
{ zone: "America/Argentina/Buenos_Aires", code: "ART", label: "Buenos Aires" },
{ zone: "Atlantic/Azores", code: "AZOT", label: "Azores" },
{ zone: "Europe/London", code: "GMT", label: "London, Dublin, Lisbon" },
{ zone: "Europe/Berlin", code: "CET", label: "Berlin, Paris, Madrid, Rome" },
{ zone: "Europe/Athens", code: "EET", label: "Athens, Helsinki, Bucharest" },
{ zone: "Europe/Moscow", code: "MSK", label: "Moscow, Istanbul" },
{ zone: "Asia/Dubai", code: "GST", label: "Dubai, Abu Dhabi" },
{ zone: "Asia/Karachi", code: "PKT", label: "Karachi, Tashkent" },
{ zone: "Asia/Kolkata", code: "IST", label: "Mumbai, Delhi, Bengaluru" },
{ zone: "Asia/Dhaka", code: "BST", label: "Dhaka" },
{ zone: "Asia/Bangkok", code: "ICT", label: "Bangkok, Jakarta" },
{ zone: "Asia/Singapore", code: "SGT", label: "Singapore, Kuala Lumpur" },
{ zone: "Asia/Shanghai", code: "CST", label: "Beijing, Shanghai, Hong Kong" },
{ zone: "Asia/Tokyo", code: "JST", label: "Tokyo, Seoul" },
{ zone: "Australia/Perth", code: "AWST", label: "Perth" },
{ zone: "Australia/Adelaide", code: "ACT", label: "Adelaide" },
{ zone: "Australia/Sydney", code: "AEST", label: "Sydney, Melbourne" },
{ zone: "Pacific/Auckland", code: "NZT", label: "Auckland" },
{ zone: "UTC", code: "UTC", label: "Coordinated Universal Time" },
];
// Compute the current offset (minutes) for an IANA zone using Intl.
// Positive means ahead of UTC.
export function getOffsetMinutes(zone: string, at: Date = new Date()): number {
try {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "shortOffset",
});
const parts = dtf.formatToParts(at);
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value || "";
// shortOffset returns like "GMT+01:00" or "GMT-5". Parse accordingly.
const m = tzPart.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!m) return 0;
const sign = m[1] === "-" ? -1 : 1;
const hours = parseInt(m[2] || "0", 10);
const mins = parseInt(m[3] || "0", 10);
return sign * (hours * 60 + mins);
} catch {
return 0;
}
}
// Format minutes-offset as "+02:00" / "-05:30".
export function formatOffset(minutes: number): string {
const sign = minutes >= 0 ? "+" : "-";
const abs = Math.abs(minutes);
const h = Math.floor(abs / 60).toString().padStart(2, "0");
const m = (abs % 60).toString().padStart(2, "0");
return `${sign}${h}:${m}`;
}
// Build a one-line label like "(UTC+01:00) CET — Berlin, Paris, …".
export function formatTimezone(opt: TimezoneOption, at: Date = new Date()): string {
const off = formatOffset(getOffsetMinutes(opt.zone, at));
return `(UTC${off}) ${opt.code}${opt.label}`;
}
// Get current time in a zone formatted as "HH:mm" (24h) or "h:mm a" (12h).
export function formatTimeInZone(zone: string, format: "12h" | "24h" = "24h", at: Date = new Date()): string {
return new Intl.DateTimeFormat("en-GB", {
timeZone: zone,
hour: "2-digit",
minute: "2-digit",
hour12: format === "12h",
}).format(at);
}
// Sort by offset asc, then by code/IANA name. Returns a fresh array.
export function sortedTimezones(at: Date = new Date()): TimezoneOption[] {
return [...TIMEZONE_OPTIONS].sort((a, b) => {
const oa = getOffsetMinutes(a.zone, at);
const ob = getOffsetMinutes(b.zone, at);
if (oa !== ob) return oa - ob;
if (a.code !== b.code) return a.code.localeCompare(b.code);
return a.zone.localeCompare(b.zone);
});
}

View File

@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "es2017",
"downlevelIteration": true,
"lib": [
"dom",
"dom.iterable",
@ -13,7 +14,7 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",

File diff suppressed because one or more lines are too long