Compare commits

...

10 Commits

Author SHA1 Message Date
4a18027068 fix: refresh calendar immediately after drag-move of event
Some checks failed
Playwright Tests / test (push) Has been cancelled
After dragging a calendar event to a new time or date, trigger
fetchCalendarEvents on both the old and new date context so the
move is reflected instantly without waiting for the next poll cycle.
Applies to both non-recurring and recurring drag confirms.

v1.112.2
2026-06-10 10:29:45 +02:00
4f140baa38 feat: move jump-to-date icon left of nav pill, picker opens below
- Calendar icon is now a standalone button to the left of the << < Today > >> cluster
- Uses its own ref (navDatePickerBtnRef) so SimpleDatePicker anchors and
  opens directly below the icon instead of at the center date display
- Removed separator inside nav pill (no longer needed)

v1.112.1
2026-05-29 12:14:34 +02:00
3cb59583ff chore: update .gitignore to exclude APP_ERKLÄRUNG.md
v1.112.1
2026-05-29 12:01:38 +02:00
e7eb060643 feat: add location autocomplete and custom reminder times to event modal
- Location field shows dropdown suggestions from previously saved locations
  as the user types; selected/submitted locations are persisted to
  profile.viewSettings.savedLocations (max 20, most recent first)
- Reminder dropdown gains a "Custom…" option that opens an inline editor
  for entering X minutes / hours / days before the event; confirmed custom
  values are persisted to profile.viewSettings.customReminderMinutes
  (max 10) and appear in future reminder dropdowns

v1.112.0
2026-05-29 11:53:03 +02:00
530006060a feat: add jump-to-date button to hiding top toolbar left section
v1.111.6
2026-05-29 11:51:54 +02:00
b9ed032f1f new handels on the allday and anyday areas 2026-05-13 19:06:39 +02:00
0a67745b85 Raise resize handle stacking 2026-05-13 00:49:02 +02:00
5ebdf6d751 Refine sidebar header controls and resize handles 2026-05-13 00:43:19 +02:00
07401d3dfa fix: restore Printer icon to collapsed desktop rail
Print button was accidentally dropped when the history flyout was
slimmed to undo/redo only. Restored next to Refresh in the rail.

v1.111.5
2026-05-11 15:52:04 +02:00
0e189e53b3 fix: vertically center icons, checkbox, and text in task rows
Changed alignItems from flex-start to center on outer div and span.
Removed manual top/left offsets on checkbox (no longer needed with
center alignment). Icon column uses justifyContent: center.

v1.111.4
2026-05-11 15:43:14 +02:00
10 changed files with 698 additions and 81 deletions

0
�@p9N@8 Normal file
View File

5
.gitignore vendored
View File

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

255
docs/DESIGN_NOTES.md Normal file
View File

@ -0,0 +1,255 @@
# 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,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.111.3", "version": "1.112.2",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -3270,26 +3270,29 @@ h3 {
/* Resize handle for draggable section borders */ /* Resize handle for draggable section borders */
.resize-handle { .resize-handle {
height: 7px; height: 2px;
border: 1px solid #ccc;
cursor: ns-resize; cursor: ns-resize;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none; user-select: none;
touch-action: none; touch-action: none;
position: relative; position: relative;
z-index: 10; z-index: 50;
flex-shrink: 0; flex-shrink: 0;
border-top: 1px solid var(--weekly-border, #e5e7eb); overflow: visible;
border-bottom: 1px solid var(--weekly-border, #e5e7eb); background-color: #ddd;
} }
.resize-handle:hover { .resize-handle:hover {
background: rgba(59, 130, 246, 0.08); background-color: #d7d7d7;
border-color: var(--weekly-accent, #3b82f6); border-color: #b8b8b8;
} }
.resize-handle:active { .resize-handle:active {
background: rgba(59, 130, 246, 0.12); background-color: #d1d1d1;
border-color: var(--weekly-accent, #3b82f6); border-color: #aaa;
} }
.resize-handle-bar { .resize-handle-bar {
width: 32px; width: 32px;
@ -3299,10 +3302,50 @@ h3 {
opacity: 0.6; opacity: 0.6;
transition: background 0.15s, opacity 0.15s; 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:hover .resize-handle-bar,
.resize-handle:active .resize-handle-bar { .resize-handle:active .resize-handle-bar {
background: var(--weekly-accent, #3b82f6); opacity: 1;
opacity: 0.5; }
.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;
} }
.all-day-events-header { .all-day-events-header {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react'; 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 } from 'lucide-react'; import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity, Check } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons'; import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faServer } from '@fortawesome/free-solid-svg-icons'; import { faServer } from '@fortawesome/free-solid-svg-icons';
@ -27,7 +27,7 @@ function AnimatedDots() {
} }
// Reminder preset options (minutes) // Reminder preset options (minutes)
const REMINDER_OPTIONS = [ const REMINDER_PRESETS = [
{ label: 'None', value: -1 }, { label: 'None', value: -1 },
{ label: 'At time of event', value: 0 }, { label: 'At time of event', value: 0 },
{ label: '5 minutes before', value: 5 }, { label: '5 minutes before', value: 5 },
@ -40,6 +40,25 @@ const REMINDER_OPTIONS = [
{ label: '1 week before', value: 10080 }, { 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 = [ const BUSY_STATUS_OPTIONS = [
{ label: 'Busy', value: 'busy' }, { label: 'Busy', value: 'busy' },
{ label: 'Free', value: 'free' }, { label: 'Free', value: 'free' },
@ -63,6 +82,10 @@ interface CalendarEventModalProps {
connections: any[]; connections: any[];
weekStartDay?: number; // 0=Sunday, 1=Monday weekStartDay?: number; // 0=Sunday, 1=Monday
language?: string; language?: string;
savedLocations?: string[];
onSaveLocation?: (loc: string) => void;
customReminderMinutes?: number[];
onSaveCustomReminder?: (minutes: number) => void;
onClose: () => void; onClose: () => void;
onSave: (eventData: any) => Promise<void>; onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>; onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
@ -76,6 +99,10 @@ export default function CalendarEventModal({
connections, connections,
weekStartDay = 0, weekStartDay = 0,
language = 'en', language = 'en',
savedLocations = [],
onSaveLocation,
customReminderMinutes = [],
onSaveCustomReminder,
onClose, onClose,
onSave, onSave,
onDelete onDelete
@ -167,6 +194,53 @@ export default function CalendarEventModal({
const calendarSelectorRef = useRef<HTMLDivElement>(null); const calendarSelectorRef = useRef<HTMLDivElement>(null);
const dialogRef = 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 // Focus trap
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@ -261,6 +335,11 @@ export default function CalendarEventModal({
return; return;
} }
// Save location to user profile if non-empty and new
if (location.trim() && onSaveLocation) {
onSaveLocation(location.trim());
}
setIsSaving(true); setIsSaving(true);
setError(''); setError('');
try { try {
@ -348,11 +427,29 @@ export default function CalendarEventModal({
const updateReminder = (index: number, minutes: number) => { const updateReminder = (index: number, minutes: number) => {
if (minutes === -1) { if (minutes === -1) {
setReminders(reminders.filter((_, i) => i !== index)); setReminders(reminders.filter((_, i) => i !== index));
setCustomEditorIdx(null);
return;
}
if (minutes === CUSTOM_REMINDER_SENTINEL) {
setCustomEditorIdx(index);
setCustomAmount(30);
setReminderUnit('minutes');
return; return;
} }
const updated = [...reminders]; const updated = [...reminders];
updated[index] = { ...updated[index], minutes }; updated[index] = { ...updated[index], minutes };
setReminders(updated); 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 = () => { const addReminder = () => {
@ -687,24 +784,61 @@ export default function CalendarEventModal({
</div> </div>
)} )}
{/* Location */} {/* Location with autocomplete */}
<div style={iconRow}> <div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div> <div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
<input <input
type="text" type="text"
value={location} value={location}
onChange={e => setLocation(e.target.value)} 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([]); }}
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'} placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
aria-label={language === 'de' ? 'Ort' : 'Location'} aria-label={language === 'de' ? 'Ort' : 'Location'}
aria-autocomplete="list"
aria-expanded={locationSuggestions.length > 0}
style={{ style={{
...fieldCol, padding: '2px 0', border: 'none', ...fieldCol, padding: '2px 0', border: 'none',
background: 'transparent', outline: 'none', fontSize: '0.8rem', background: 'transparent', outline: 'none', fontSize: '0.8rem',
color: 'var(--weekly-text)', 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> </div>
{/* Alert */} {/* Alert / Reminders */}
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div> <div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}> <div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
@ -714,23 +848,79 @@ export default function CalendarEventModal({
</button> </button>
) : ( ) : (
reminders.map((reminder, idx) => ( reminders.map((reminder, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> <div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<select <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
value={reminder.minutes} <select
onChange={e => updateReminder(idx, parseInt(e.target.value))} value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`} onChange={e => updateReminder(idx, parseInt(e.target.value))}
style={{ ...inlineSelect, flex: 1 }} aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
> style={{ ...inlineSelect, flex: 1 }}
{REMINDER_OPTIONS.map(opt => ( >
<option key={opt.value} value={opt.value}>{opt.label}</option> {buildReminderOptions().map(opt => (
))} <option key={opt.value} value={opt.value}>{opt.label}</option>
</select> ))}
<button </select>
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))} <button
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`} onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}> aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
<X size={12} aria-hidden="true" /> style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
</button> <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> </div>
)) ))
)} )}

View File

@ -299,7 +299,7 @@ export function GridTaskBlock({
if (editingTaskId !== task.id) toggleTask(task.id); if (editingTaskId !== task.id) toggleTask(task.id);
}} }}
> >
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
{editingTaskId === task.id ? ( {editingTaskId === task.id ? (
<form <form
onSubmit={(e) => { onSubmit={(e) => {
@ -329,7 +329,7 @@ export function GridTaskBlock({
<span <span
style={{ style={{
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "center",
gap: "3px", gap: "3px",
overflow: "visible", overflow: "visible",
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
@ -342,7 +342,7 @@ export function GridTaskBlock({
}} }}
> >
{(showPriorityIcons || showProjectIcons) && ( {(showPriorityIcons || showProjectIcons) && (
<span style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-start", width: "14px", minWidth: "14px", flexShrink: 0, gap: "2px", paddingTop: "1px", opacity: task.completed && showTaskCheckboxes ? 0.5 : 1 }}> <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 && (() => { {showPriorityIcons && (() => {
const pb = getPriorityBadge(task, priorityStyle, 11); const pb = getPriorityBadge(task, priorityStyle, 11);
return pb ? ( return pb ? (
@ -380,7 +380,7 @@ export function GridTaskBlock({
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }} onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0" className="task-checkbox flex-shrink-0"
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }} style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", flexShrink: 0, 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={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" }}>
@ -510,7 +510,7 @@ export function GridTaskBlock({
synology: { icon: faServer, color: "#007AFF", label: "Synology" }, synology: { icon: faServer, color: "#007AFF", label: "Synology" },
}; };
return ( return (
<span style={{ display: "flex", alignItems: "flex-start", gap: "3px", flexShrink: 0, paddingTop: "2px" }}> <span style={{ display: "flex", alignItems: "center", gap: "3px", flexShrink: 0 }}>
{rolling && ( {rolling && (
<span title="Auto-rolling task" style={{ display: "inline-flex", alignItems: "center", color: "var(--weekly-teal, #009a9a)" }}> <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"> <svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">

View File

@ -1045,6 +1045,41 @@ function SettingsSidebar({
</button> </button>
))} ))}
</div> </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> </div>
{/* Header Display */} {/* Header Display */}
<div> <div>

View File

@ -128,6 +128,7 @@ type DeviceViewSettingKey = typeof DEVICE_VIEW_SETTINGS_KEYS[number];
// Settings that save to DB (cross-device default) AND to cookie (device override wins on load) // Settings that save to DB (cross-device default) AND to cookie (device override wins on load)
const DEVICE_ALSO_COOKIE_KEYS = ["viewStyle", "showTimeGrid", "startDayOffset"]; const DEVICE_ALSO_COOKIE_KEYS = ["viewStyle", "showTimeGrid", "startDayOffset"];
const VIEW_SETTINGS_PROFILE_KEYS = ["menuPosition", "showHeaderControls"];
function getCookie(name: string): string | null { function getCookie(name: string): string | null {
if (typeof document === "undefined") return null; if (typeof document === "undefined") return null;
@ -918,6 +919,7 @@ export default function WeeklyView() {
// Mobile detection // Mobile detection
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [isPortrait, setIsPortrait] = useState(false); const [isPortrait, setIsPortrait] = useState(false);
const [isCompactHeight, setIsCompactHeight] = useState(false);
// Tracks the last day column the user interacted with (for "selected day" header display) // Tracks the last day column the user interacted with (for "selected day" header display)
const [selectedDay, setSelectedDay] = useState<Date | null>(null); const [selectedDay, setSelectedDay] = useState<Date | null>(null);
@ -1037,6 +1039,8 @@ export default function WeeklyView() {
weatherLon: null, weatherLon: null,
weatherLocation: "", weatherLocation: "",
showCalendarProviderIcon: false, showCalendarProviderIcon: false,
menuPosition: "left",
showHeaderControls: true,
}); });
const [motivationalQuote, setMotivationalQuote] = useState(""); const [motivationalQuote, setMotivationalQuote] = useState("");
const [showSummary, setShowSummary] = useState(false); const [showSummary, setShowSummary] = useState(false);
@ -1257,6 +1261,8 @@ export default function WeeklyView() {
const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false); const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false);
const [showDatePicker, setShowDatePicker] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false);
const datePickerBtnRef = useRef<HTMLDivElement>(null); const datePickerBtnRef = useRef<HTMLDivElement>(null);
const [showNavDatePicker, setShowNavDatePicker] = useState(false);
const navDatePickerBtnRef = useRef<HTMLButtonElement>(null);
const [showQuickSettings, setShowQuickSettings] = useState(false); const [showQuickSettings, setShowQuickSettings] = useState(false);
const [leftRailExpanded, setLeftRailExpanded] = useState(false); const [leftRailExpanded, setLeftRailExpanded] = useState(false);
const [flyoutSection, setFlyoutSection] = useState<string | null>(null); const [flyoutSection, setFlyoutSection] = useState<string | null>(null);
@ -1400,6 +1406,7 @@ export default function WeeklyView() {
const check = () => { const check = () => {
setIsMobile(window.innerWidth <= 768); setIsMobile(window.innerWidth <= 768);
setIsPortrait(window.innerHeight > window.innerWidth); setIsPortrait(window.innerHeight > window.innerWidth);
setIsCompactHeight(window.innerHeight <= 720);
}; };
check(); check();
window.addEventListener("resize", check); window.addEventListener("resize", check);
@ -1416,7 +1423,12 @@ export default function WeeklyView() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data && data.user) { if (data && data.user) {
const profileData = data.user; const viewPrefs = (data.user.viewSettings || {}) as any;
const profileData = {
...data.user,
menuPosition: viewPrefs.menuPosition ?? data.user.menuPosition ?? "left",
showHeaderControls: viewPrefs.showHeaderControls ?? data.user.showHeaderControls ?? true,
};
setProfile(profileData); setProfile(profileData);
// Sync individual states to profile data // Sync individual states to profile data
@ -2403,6 +2415,22 @@ export default function WeeklyView() {
somedayWheelCleanup.current = () => node.removeEventListener("wheel", handler); somedayWheelCleanup.current = () => node.removeEventListener("wheel", handler);
}, []); }, []);
const saveSetting = async (key: string, value: any) => { const saveSetting = async (key: string, value: any) => {
if (VIEW_SETTINGS_PROFILE_KEYS.includes(key)) {
const updated = { ...(viewSettingsRef.current as any), [key]: value };
viewSettingsRef.current = updated;
setViewSettings(updated as any);
setProfile((p: any) => ({ ...p, [key]: value }));
try {
await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ viewSettings: updated }),
});
} catch (err) {
console.error(`Failed to save setting ${key}:`, err);
}
return;
}
// Per-device settings: save to cookie ONLY (not DB) so each device keeps its own value // Per-device settings: save to cookie ONLY (not DB) so each device keeps its own value
if (DEVICE_SETTINGS_KEYS.includes(key)) { if (DEVICE_SETTINGS_KEYS.includes(key)) {
setCookie(`setting_${key}`, String(value)); setCookie(`setting_${key}`, String(value));
@ -3159,6 +3187,9 @@ export default function WeeklyView() {
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime } ? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
: ev : ev
)); ));
} else {
const connId = getConnectionIdForCalendar(eventDragState.calendarId);
fetchCalendarEvents(true, connId);
} }
} catch { } catch {
setRawCalendarEvents(prev => prev.map(ev => setRawCalendarEvents(prev => prev.map(ev =>
@ -3178,7 +3209,7 @@ export default function WeeklyView() {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
}, [eventDragState, effectiveCellDuration, calendarEvents]); }, [eventDragState, effectiveCellDuration, calendarEvents, fetchCalendarEvents, getConnectionIdForCalendar]);
// Slot drag-to-create: mousemove + mouseup on document (always active, ref-gated) // Slot drag-to-create: mousemove + mouseup on document (always active, ref-gated)
const effectiveCellDurationRef = useRef(effectiveCellDuration); const effectiveCellDurationRef = useRef(effectiveCellDuration);
@ -3428,6 +3459,9 @@ export default function WeeklyView() {
setRawCalendarEvents(prev => prev.map(ev => setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev
)); ));
} else {
const connId = getConnectionIdForCalendar(calendarId);
fetchCalendarEvents(true, connId);
} }
} catch { } catch {
setRawCalendarEvents(prev => prev.map(ev => setRawCalendarEvents(prev => prev.map(ev =>
@ -5383,6 +5417,10 @@ export default function WeeklyView() {
}; };
const activeTheme = (darkMode ? profile.darkTheme : profile.lightTheme) as Record<string, string> | null; const activeTheme = (darkMode ? profile.darkTheme : profile.lightTheme) as Record<string, string> | null;
const useLeftRail = !isMobile && profile.menuPosition !== "top"; const useLeftRail = !isMobile && profile.menuPosition !== "top";
const showHeaderControls = profile.showHeaderControls !== false;
const compactRail = useLeftRail && isCompactHeight;
const collapsedRailWidth = compactRail ? 38 : 44;
const expandedRailWidth = compactRail ? 220 : 240;
const containerStyle = { const containerStyle = {
...(activeTheme ? { ...(activeTheme ? {
@ -5461,7 +5499,7 @@ export default function WeeklyView() {
"--weekly-past-color": activeTheme?.color8 || (darkMode "--weekly-past-color": activeTheme?.color8 || (darkMode
? invertColor(profile.pastDayColor || "#a6a6a7") ? invertColor(profile.pastDayColor || "#a6a6a7")
: profile.pastDayColor || "#a6a6a7"), : profile.pastDayColor || "#a6a6a7"),
...(useLeftRail ? { paddingLeft: leftRailExpanded ? "240px" : "44px", transition: "padding-left 0.2s ease" } : {}), ...(useLeftRail ? { paddingLeft: leftRailExpanded ? `${expandedRailWidth}px` : `${collapsedRailWidth}px`, transition: "padding-left 0.2s ease" } : {}),
} as React.CSSProperties; } as React.CSSProperties;
if (isLoading) { if (isLoading) {
@ -5492,12 +5530,12 @@ export default function WeeklyView() {
}); });
const railIconBtnStyle: React.CSSProperties = { const railIconBtnStyle: React.CSSProperties = {
width: "36px", height: "36px", display: "flex", alignItems: "center", justifyContent: "center", width: compactRail ? "30px" : "36px", height: compactRail ? "30px" : "36px", display: "flex", alignItems: "center", justifyContent: "center",
background: "none", border: "none", cursor: "pointer", borderRadius: "8px", background: "none", border: "none", cursor: "pointer", borderRadius: compactRail ? "7px" : "8px",
color: darkMode ? "#9ca3af" : "#6b7280", flexShrink: 0, color: darkMode ? "#9ca3af" : "#6b7280", flexShrink: 0,
}; };
const flyoutPanelStyle: React.CSSProperties = { const flyoutPanelStyle: React.CSSProperties = {
position: "fixed", left: "48px", position: "fixed", left: `${collapsedRailWidth + 4}px`,
top: `${Math.min(flyoutY, (typeof window !== "undefined" ? window.innerHeight : 800) - 260)}px`, top: `${Math.min(flyoutY, (typeof window !== "undefined" ? window.innerHeight : 800) - 260)}px`,
background: darkMode ? "#1a1a2e" : "var(--paper)", background: darkMode ? "#1a1a2e" : "var(--paper)",
border: "1px solid var(--line)", borderRadius: "10px", padding: "12px 14px", border: "1px solid var(--line)", borderRadius: "10px", padding: "12px 14px",
@ -5521,7 +5559,7 @@ export default function WeeklyView() {
if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current); if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current);
}; };
const railSep = ( const railSep = (
<div style={{ height: "1px", background: "var(--line)", width: "28px", margin: "3px 0", flexShrink: 0, alignSelf: "center" }} /> <div style={{ height: "1px", background: "var(--line)", width: compactRail ? "22px" : "28px", margin: compactRail ? "1px 0" : "3px 0", flexShrink: 0, alignSelf: "center" }} />
); );
// All-Day Events Section (reusable for above/below positioning) // All-Day Events Section (reusable for above/below positioning)
@ -5535,7 +5573,7 @@ export default function WeeklyView() {
const handleOnTop = effectiveAllDayPosition === "below"; const handleOnTop = effectiveAllDayPosition === "below";
const resizeHandle = isAllDayExpanded ? ( const resizeHandle = isAllDayExpanded ? (
<div <div
className="resize-handle" className="resize-handle resize-handle-allday"
onMouseDown={(e) => startResize(e, 'allday', handleOnTop)} onMouseDown={(e) => startResize(e, 'allday', handleOnTop)}
onTouchStart={(e) => startResize(e, 'allday', handleOnTop)} onTouchStart={(e) => startResize(e, 'allday', handleOnTop)}
> >
@ -5830,10 +5868,10 @@ export default function WeeklyView() {
{useLeftRail && ( {useLeftRail && (
<> <>
{/* The rail panel */} {/* The rail panel */}
<div style={{ position: "fixed", left: 0, top: 0, bottom: 0, width: leftRailExpanded ? "240px" : "44px", transition: "width 0.2s ease", background: darkMode ? "#1a1a2e" : "var(--paper)", borderRight: "1px solid var(--line)", zIndex: 100, display: "flex", flexDirection: "column", overflow: "hidden" }}> <div className={`weekly-left-sidebar ${leftRailExpanded ? "expanded" : "collapsed"} ${compactRail ? "compact-height" : ""}`} style={{ position: "fixed", left: 0, top: 0, bottom: 0, width: leftRailExpanded ? `${expandedRailWidth}px` : `${collapsedRailWidth}px`, transition: "width 0.2s ease", background: darkMode ? "#1a1a2e" : "var(--paper)", borderRight: "1px solid var(--line)", zIndex: 100, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{leftRailExpanded ? ( {leftRailExpanded ? (
// ── EXPANDED MODE ── // ── EXPANDED MODE ──
<div style={{ padding: "16px 14px", display: "flex", flexDirection: "column", gap: "10px", overflowY: "auto", flex: 1 }}> <div style={{ padding: compactRail ? "10px 10px" : "16px 14px", display: "flex", flexDirection: "column", gap: compactRail ? "6px" : "10px", overflowY: "auto", flex: 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333" }}>{profile.language === "de" ? "Einstellungen" : "Preferences"}</span> <span style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333" }}>{profile.language === "de" ? "Einstellungen" : "Preferences"}</span>
<button onClick={() => setLeftRailExpanded(false)} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#6b7280" : "#9ca3af", padding: "2px", display: "flex" }} title="Collapse"> <button onClick={() => setLeftRailExpanded(false)} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#6b7280" : "#9ca3af", padding: "2px", display: "flex" }} title="Collapse">
@ -5902,7 +5940,7 @@ export default function WeeklyView() {
</div> </div>
) : ( ) : (
// ── COLLAPSED MODE — icon strip ── // ── COLLAPSED MODE — icon strip ──
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "6px", paddingBottom: "8px", height: "100%", overflowY: "auto", overflowX: "visible" }}> <div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: compactRail ? "4px" : "6px", paddingBottom: compactRail ? "4px" : "8px", height: "100%", overflowY: compactRail ? "hidden" : "auto", overflowX: "visible" }}>
{/* Expand toggle */} {/* Expand toggle */}
<button onClick={() => setLeftRailExpanded(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Erweitern" : "Expand"}><PanelLeftOpen size={16} /></button> <button onClick={() => setLeftRailExpanded(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Erweitern" : "Expand"}><PanelLeftOpen size={16} /></button>
{railSep} {railSep}
@ -5932,8 +5970,9 @@ export default function WeeklyView() {
{railSep} {railSep}
{/* History flyout (undo / redo) */} {/* History flyout (undo / redo) */}
<button style={{ ...railIconBtnStyle, opacity: undoCount === 0 && redoCount === 0 ? 0.5 : 1 }} title={profile.language === "de" ? "Verlauf" : "History"} onMouseEnter={(e) => openFlyout("history", e)} onMouseLeave={closeFlyoutDelayed}><Undo2 size={15} /></button> <button style={{ ...railIconBtnStyle, opacity: undoCount === 0 && redoCount === 0 ? 0.5 : 1 }} title={profile.language === "de" ? "Verlauf" : "History"} onMouseEnter={(e) => openFlyout("history", e)} onMouseLeave={closeFlyoutDelayed}><Undo2 size={15} /></button>
{/* Refresh */} {/* Refresh + Print */}
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); }} style={{ ...railIconBtnStyle }} title="Refresh"><RefreshCcw size={15} /></button> <button onClick={() => { fetchCalendarEvents(true); fetchTasks(); }} style={{ ...railIconBtnStyle }} title="Refresh"><RefreshCcw size={15} /></button>
<button onClick={() => setShowWeekPrintModal(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Drucken" : "Print"}><Printer size={15} /></button>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
{railSep} {railSep}
{/* Settings (cogwheel) + User menu at bottom */} {/* Settings (cogwheel) + User menu at bottom */}
@ -6242,7 +6281,7 @@ export default function WeeklyView() {
)} )}
{/* Desktop Header: Left, Center, Right */} {/* Desktop Header: Left, Center, Right */}
<header className="group relative flex items-center justify-between w-full px-4 py-1.5 border-b bg-white dark:bg-gray-900 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : { borderBottomColor: 'var(--line)', minHeight: useLeftRail ? "44px" : undefined }}> <header className="group relative flex items-center justify-between w-full px-4 py-1.5 border-b bg-white dark:bg-gray-900 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : { borderBottomColor: 'var(--line)', minHeight: useLeftRail ? (compactRail ? "40px" : "44px") : undefined }}>
{/* LEFT SECTION: View Switcher, Days, Hours, Slot Duration — only shown in top-toolbar mode */} {/* LEFT SECTION: View Switcher, Days, Hours, Slot Duration — only shown in top-toolbar mode */}
<div className={`weekly-header-controls flex items-center gap-4 transition-opacity duration-700 ${!useLeftRail ? "opacity-0 group-hover:opacity-100" : ""}`} style={{ zIndex: 1, display: useLeftRail ? "none" : undefined, ...(showIntroHints ? { opacity: 1 } : {}) }}> <div className={`weekly-header-controls flex items-center gap-4 transition-opacity duration-700 ${!useLeftRail ? "opacity-0 group-hover:opacity-100" : ""}`} style={{ zIndex: 1, display: useLeftRail ? "none" : undefined, ...(showIntroHints ? { opacity: 1 } : {}) }}>
{/* Quick-settings button (top mode only) */} {/* Quick-settings button (top mode only) */}
@ -6338,6 +6377,15 @@ export default function WeeklyView() {
</div> </div>
)} )}
{/* Jump to Date — mirrored from nav cluster */}
<button
className="p-1 hover:bg-white dark:hover:bg-gray-700 hover:shadow-sm rounded text-gray-500 hover:text-black dark:hover:text-white transition-all"
onClick={() => setShowDatePicker(true)}
title={profile.language === "de" ? "Datum wählen" : "Jump to date"}
>
<CalendarDays size={15} />
</button>
</div> </div>
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */} {/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
@ -6502,27 +6550,50 @@ export default function WeeklyView() {
</div> </div>
{/* RIGHT SECTION: Navigation & Tools */} {/* RIGHT SECTION: Navigation & Tools */}
<div className="weekly-header-controls flex-shrink-0 flex items-center gap-1.5 sm:gap-2 transition-opacity duration-700 opacity-0 group-hover:opacity-100" style={{ zIndex: 10, display: useLeftRail ? "none" : undefined, ...(showIntroHints ? { opacity: 1 } : {}) }}> <div className={`weekly-header-controls flex-shrink-0 flex items-center gap-1.5 sm:gap-2 transition-opacity duration-700 ${useLeftRail ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`} style={{ zIndex: 10, marginLeft: useLeftRail ? "auto" : undefined, display: useLeftRail && !showHeaderControls ? "none" : undefined, ...(showIntroHints ? { opacity: 1 } : {}) }}>
{/* Secondary actions — hidden on tablet, visible on desktop */} {/* Secondary actions — hidden on tablet, visible on desktop */}
<div className="header-desktop-only flex items-center gap-1.5"> {!useLeftRail && (
<button onClick={handleUndo} disabled={undoCount === 0} className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default" title="Undo (Ctrl+Z)"> <div className="header-desktop-only flex items-center gap-1.5">
<Undo2 size={17} className="text-gray-600 hover:text-black transition-colors" /> <button onClick={handleUndo} disabled={undoCount === 0} className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default" title="Undo (Ctrl+Z)">
</button> <Undo2 size={17} className="text-gray-600 hover:text-black transition-colors" />
<button onClick={handleRedo} disabled={redoCount === 0} className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default" title="Redo (Ctrl+Y)"> </button>
<Redo2 size={17} className="text-gray-600 hover:text-black transition-colors" /> <button onClick={handleRedo} disabled={redoCount === 0} className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default" title="Redo (Ctrl+Y)">
</button> <Redo2 size={17} className="text-gray-600 hover:text-black transition-colors" />
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} className="weekly-btn-icon" title="Add Calendar Event"> </button>
<Plus size={17} className="text-gray-600 hover:text-black transition-colors" /> <button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} className="weekly-btn-icon" title="Add Calendar Event">
</button> <Plus size={17} className="text-gray-600 hover:text-black transition-colors" />
<button onClick={() => { const newVal = !profile.showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); }} className={`weekly-btn-icon ${profile.showNextTask ? "active" : ""}`} title={profile.showNextTask ? "Showing Next Task" : "Showing Goal"}> </button>
{profile.showNextTask ? <Play size={17} className="text-teal-600" /> : <Target size={17} className="text-gray-400" />} <button onClick={() => { const newVal = !profile.showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); }} className={`weekly-btn-icon ${profile.showNextTask ? "active" : ""}`} title={profile.showNextTask ? "Showing Next Task" : "Showing Goal"}>
</button> {profile.showNextTask ? <Play size={17} className="text-teal-600" /> : <Target size={17} className="text-gray-400" />}
<button onClick={() => setShowFocusMode(true)} className="weekly-btn-icon" title="Enter Focus Mode"> </button>
<Zap size={17} className="text-gray-600 hover:text-yellow-500 transition-colors" /> <button onClick={() => setShowFocusMode(true)} className="weekly-btn-icon" title="Enter Focus Mode">
</button> <Zap size={17} className="text-gray-600 hover:text-yellow-500 transition-colors" />
<button onClick={() => setDarkMode(!darkMode)} className="weekly-btn-icon" title={darkMode ? "Light Mode" : "Dark Mode"}> </button>
{darkMode ? <Sun size={17} className="text-yellow-500" /> : <Moon size={17} className="text-gray-500" />} <button onClick={() => setDarkMode(!darkMode)} className="weekly-btn-icon" title={darkMode ? "Light Mode" : "Dark Mode"}>
{darkMode ? <Sun size={17} className="text-yellow-500" /> : <Moon size={17} className="text-gray-500" />}
</button>
</div>
)}
{/* Jump to Date — left of nav, picker opens below */}
<div style={{ position: "relative" }}>
<button
ref={navDatePickerBtnRef}
className="weekly-btn-icon"
onClick={() => setShowNavDatePicker(v => !v)}
title={profile.language === "de" ? "Datum wählen" : "Jump to date"}
>
<Calendar size={17} />
</button> </button>
{showNavDatePicker && !isMobile && (
<SimpleDatePicker
selected={currentWeekStart}
onSelect={(date) => { setCurrentWeekStart(getStartOfWeek(date)); setShowNavDatePicker(false); }}
onClose={() => setShowNavDatePicker(false)}
language={profile.language}
anchorRef={navDatePickerBtnRef}
/>
)}
</div> </div>
{/* Navigation Controls — always visible */} {/* Navigation Controls — always visible */}
@ -6539,7 +6610,7 @@ export default function WeeklyView() {
<button className="weekly-btn-icon" onClick={() => setShowSettings(true)} title="Settings"><Settings size={17} /></button> <button className="weekly-btn-icon" onClick={() => setShowSettings(true)} title="Settings"><Settings size={17} /></button>
{/* Overflow menu — visible on tablet, hidden on desktop */} {/* Overflow menu — visible on tablet, hidden on desktop */}
<div className="header-tablet-only" style={{ position: "relative" }}> <div className={useLeftRail ? "" : "header-tablet-only"} style={{ position: "relative" }}>
<button className="weekly-btn-icon" onClick={() => setShowHeaderMore(!showHeaderMore)} title="More"> <button className="weekly-btn-icon" onClick={() => setShowHeaderMore(!showHeaderMore)} title="More">
<MoreVertical size={17} /> <MoreVertical size={17} />
</button> </button>
@ -6588,9 +6659,13 @@ export default function WeeklyView() {
</div> </div>
{/* Desktop only: Recurring Tasks + New Project */} {/* Desktop only: Recurring Tasks + New Project */}
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button> {!useLeftRail && (
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowProjectsSidebar(true)} title="New Project"><FolderPlus size={17} /></button> <>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowWeekPrintModal(true)} title={profile.language === "de" ? "Woche drucken / exportieren" : "Print / export week"}><Printer size={17} /></button> <button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowProjectsSidebar(true)} title="New Project"><FolderPlus size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowWeekPrintModal(true)} title={profile.language === "de" ? "Woche drucken / exportieren" : "Print / export week"}><Printer size={17} /></button>
</>
)}
{/* User Menu */} {/* User Menu */}
<UserMenu <UserMenu
@ -8030,7 +8105,7 @@ export default function WeeklyView() {
{/* Resize handle - on top border of someday section */} {/* Resize handle - on top border of someday section */}
{somedayExpanded && ( {somedayExpanded && (
<div <div
className="resize-handle" className="resize-handle resize-handle-anyday"
onMouseDown={(e) => startResize(e, 'someday', true)} onMouseDown={(e) => startResize(e, 'someday', true)}
onTouchStart={(e) => startResize(e, 'someday', true)} onTouchStart={(e) => startResize(e, 'someday', true)}
> >
@ -9214,6 +9289,24 @@ export default function WeeklyView() {
connections={connections} connections={connections}
weekStartDay={profile.weekStartDay ?? 1} weekStartDay={profile.weekStartDay ?? 1}
language={profile.language} language={profile.language}
savedLocations={(profile.viewSettings as any)?.savedLocations || []}
customReminderMinutes={(profile.viewSettings as any)?.customReminderMinutes || []}
onSaveLocation={(loc: string) => {
const current: string[] = (profile.viewSettings as any)?.savedLocations || [];
if (current.includes(loc)) return;
const updated = [loc, ...current].slice(0, 20);
const newVS = { ...(profile.viewSettings as any), savedLocations: updated };
setProfile((p: any) => ({ ...p, viewSettings: newVS }));
saveSetting('viewSettings', newVS);
}}
onSaveCustomReminder={(minutes: number) => {
const current: number[] = (profile.viewSettings as any)?.customReminderMinutes || [];
if (current.includes(minutes)) return;
const updated = [minutes, ...current].slice(0, 10);
const newVS = { ...(profile.viewSettings as any), customReminderMinutes: updated };
setProfile((p: any) => ({ ...p, viewSettings: newVS }));
saveSetting('viewSettings', newVS);
}}
onClose={() => onClose={() =>
setCalendarEventModal({ ...calendarEventModal, isOpen: false }) setCalendarEventModal({ ...calendarEventModal, isOpen: false })
} }
@ -11781,5 +11874,3 @@ function NotesSidebar({ task, onClose, updateTaskNotes, updateTaskUrl }: NotesSi
</> </>
); );
} }

File diff suppressed because one or more lines are too long