Compare commits

...

67 Commits

Author SHA1 Message Date
9ce5664621 chore: remove stray file with invalid filename
Some checks failed
Playwright Tests / test (push) Has been cancelled
v1.113.1
2026-07-08 23:01:27 +02:00
5510197cf4 fix: add element/weekend color controls to CSS Variables panel, color-first layout
Today Highlight, Past Days, Saturday and Sunday now also appear as their
own groups in the CSS Variables panel, bound to the same profile fields
as the existing Element/Weekend Colors controls so both stay in sync.
Every row now shows the color swatch before its label.

v1.113.1
2026-07-08 20:51:01 +02:00
02b2e40649 feat: add customizable CSS variables and custom CSS to Styling settings
Adds a "CSS Variables" panel to the Styling tab with a color picker and
description for every color token in globals.css (base, sidebar, design
token aliases, event colors, all-day chips), plus a "Custom CSS" textarea
that loads after the default stylesheet and can override any rule.

Overrides are stored per-user (customCssVars, customCss) and applied on
the root container's inline style, so they take precedence over themes
and per-field settings.

v1.113.0
2026-07-08 19:57:21 +02:00
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
4a5910779e fix: align checkboxes, right-align end icons, fix weather overlap
- borderLeft now always 4px (transparent when no project/kanban color)
  so all tasks have the same left padding (8px) and checkboxes align
- Rolling and provider icons moved from inline text flow to a right-
  aligned sibling span in the outer flex row (always at line end)
- paddingRight: 30px when weatherEnabled to prevent temperature overlay
  from overlapping task text

v1.111.3
2026-05-11 15:36:57 +02:00
10e7ba98f1 fix: always reserve icon column width for checkbox/text alignment
Icon column (14px fixed width) is now always rendered when priority or
project icon settings are enabled, even when a task has no icons.
Each icon slot is a 12×12px centered container so icons are the same
size and horizontally aligned. Increased gap to 2px for breathing room.

v1.111.2
2026-05-11 15:15:40 +02:00
cb17c91ca6 fix: replace Circle with project icon in task hover menu
The project assign button and dropdown list now show the project's
actual icon (or faFolder as fallback) instead of a plain circle,
matching the icon rendering used in the task row stacked column.

v1.111.1
2026-05-11 15:08:16 +02:00
b1e549dbfa feat: weekday task layout matches AnyDay — stacked icon column
Priority icon (top) + project icon (bottom) now rendered in a fixed
leading column before the checkbox, matching the AnyDay area layout.
Provider/sync icon always inline at end (removed absolute fallback).

v1.111.0
2026-05-11 15:07:01 +02:00
cf504846da feat: merge visibility+appearance flyout, slim history, nav stays open
- Eye icon now opens a combined vis-display flyout (visibility toggles
  + appearance settings in one panel, with divider)
- History flyout reduced to undo+redo only; Refresh moved back to
  standalone rail icon
- Navigation flyout stays open when clicking prev/next day/week
  (removed setFlyoutSection(null) from all nav button handlers,
  both desktop and mobile)
- Mobile rail mirrors desktop flyout structure (vis-display, history,
  nav, refresh standalone)

v1.110.7
2026-05-11 14:43:16 +02:00
75bb3301f9 fix(rail): collapse left rail icons to fit screens < 960px height
- Nav arrows (5 icons) → single ArrowLeftRight flyout with prev/next week/day + today
- Undo/Redo/Refresh/Print (4 icons) → single Undo2 history flyout
- Removed duplicate standalone dark mode toggle (already in Appearance flyout)
- Same flyout sections added to mobile icon rail
- Nav flyout also added to mobile rail

v1.110.6
2026-05-11 14:33:23 +02:00
73a657ebff style: remove inline min-height: 24px from task-list-slot divs
v1.110.5
2026-05-11 14:10:53 +02:00
438cd71a08 style: remove min-height from .task-list-slot
v1.110.4
2026-05-11 14:09:01 +02:00
27756e5bf5 fix: :15/:30/:45 toggle now works in left rail flyout and all sidebar panels
Was calling setShowSubHourSlots (local state) + saveSetting, but effectiveShowSubHourSlots
reads from profile via getEffective — so the toggle had no visible effect. Fixed all 5
occurrences to use saveViewSetting("showSubHourSlots", ..., true) matching the Settings
sidebar behavior.

v1.110.3
2026-05-11 14:06:51 +02:00
ab8d96a810 fix(mobile): header icon toggles icon rail; remove redundant nav arrows from rail
- PanelLeftOpen in header now toggles the icon rail open/close
- Close button in rail uses same PanelLeftOpen icon for visual consistency
- Removed nav arrows (prev/next day/week, today) from rail — available in Quick Settings
- Removed Printer from rail (rarely used on mobile)
- Added SlidersHorizontal (Quick Settings) to bottom of rail

v1.110.2
2026-05-11 13:41:41 +02:00
671c91c3d7 fix(mobile): top-left button opens Quick Settings; icon rail mirrors desktop
- PanelLeftOpen in mobile header now opens Quick Settings (left panel)
- Added "Icon Rail" link at bottom of Quick Settings to open the icon sidebar
- Mobile icon sidebar now matches collapsed desktop left rail: View, Columns,
  Slot, Plus, Repeat, Goal, Visibility, Appearance flyouts (tap-to-toggle),
  Undo, Redo, Refresh, Print, dark mode, Settings, Sign out

v1.110.1
2026-05-11 13:32:33 +02:00
81f3a42620 feat: task edit alignment + settings tabs + mobile sidebar
- Someday task edit: add icon/checkbox column spacers before the
  textarea so the edit field aligns exactly with the task text column
- Settings tabs: remove outer padding, use space-around + nowrap so all
  9 icons fit in a single row without wrapping
- Mobile: left sidebar rail (PanelLeftOpen button) — slides in from
  left with nav controls (prev/next day/week, today), actions (search,
  calendar jump, projects, focus, quick settings), and bottom controls
  (dark mode, settings, sign out). CSS class .mobile-left-sidebar and
  .mobile-rail-btn added for easy customization.

v1.110.0
2026-05-11 12:51:17 +02:00
94098fdac2 style: active tab uses box-shadow instead of border-tab effect
Removed border-bottom from tabs bar and border/margin-bottom trick from
active tab. Active tab now indicated with a subtle box-shadow.

v1.109.2
2026-05-11 12:41:19 +02:00
74268edb85 fix: align Add task input with task text column
SomedayAddTask now accepts showProjectIcons and showTaskCheckboxes props.
The bottom add-task row renders as a flex layout with matching spacers
for the icon column (18px) and checkbox column (16px), so the input
field left-edge exactly aligns with the task name column. Right-padding
keeps the input from extending into the provider icon column.

v1.109.1
2026-05-11 12:40:23 +02:00
b53eff9fd1 feat: sort completed tasks to bottom + align Add task input
- Completed tasks in someday lists now always render below all active
  tasks. Slot system builds from incomplete tasks only; done tasks get
  their own section at the bottom outside the droppable slots.
- "Add task..." input right margin increased to 24px so its right edge
  stops before the provider icon (G) column, matching task row layout.

v1.109.0
2026-05-11 12:29:01 +02:00
6325e07912 fix: align ANY DAY toggle with tab buttons in tab bar
The ANY DAY label/icon div sits at the flex-end of the tab bar but
appeared misaligned with the tab buttons. margin-bottom: 6px brings
it to the correct visual baseline.

v1.108.4
2026-05-11 12:25:52 +02:00
aa322dfac3 fix: list style popover no longer clipped by card overflow:hidden
ListVisualsPopover used position:absolute which was clipped by the
weekly-someday-list card's overflow:hidden. Now uses position:fixed
anchored to the card's bounding rect. IconPicker sub-panel also
updated to position:fixed. Stabilized mousedown listener (onCloseRef
pattern, empty deps).

v1.108.3
2026-05-11 12:19:55 +02:00
c3099d37c2 fix: remove border under active someday tab
margin-bottom was -1px but the tab bar border-bottom is 2px thick, so
the active tab only covered half the border. Changed to -2px so the
active tab fully overlaps the bar border. Also cleaned up redundant
CSS properties in the active state rule.

v1.108.2
2026-05-11 12:17:49 +02:00
5d55beeb55 fix: unify someday task icon column — all icons before checkbox
The  priority and delegation icons were still rendering inside the
text span (after the checkbox). Now ALL task icons (project, priority,
delegation) are consolidated into the single fixed-width 18px column
before the checkbox for someday tasks. Tasks with no icon get an empty
placeholder so checkboxes and task names stay column-aligned.

v1.108.1
2026-05-11 12:05:34 +02:00
55ef3614d1 feat: fix tab edit popover + align someday task items
- Fix tab double-click popover not showing: tab bar overflow-x:auto was
  clipping the position:absolute popover. Now uses position:fixed anchored
  to the button's bounding rect, captured on dblclick. Also stabilizes
  the outside-click listener (onCloseRef pattern, empty deps).
- Restructure someday task item order to: [project icon column] [checkbox]
  [task name]. Fixed-width 18px icon column (placeholder when no project)
  keeps all checkboxes and task names vertically aligned.

v1.108.0
2026-05-11 11:43:40 +02:00
6ccf61f09a feat: AnyDay tab edit via double-click; remove pencil icons; fix active tab style
CSS:
- Active tab no longer shows accent underline; instead border-bottom matches
  the someday area bg (#f9f9f9 / #1a1a1b dark), creating a raised browser-tab
  look where the active tab visually merges with the content below
- margin-bottom: -1px to cover the tab bar border-bottom on active tab

JSX:
- Double-click on any tab now opens TabVisualsPopover (edit tab)
- Pencil icon and dissolve-X button removed from tab wrappers
- TabVisualsPopover extended with: rename input, dissolve button
  (dissolve removes the tab grouping; lists are kept)
- onRename and onDissolve props added to TabVisualsPopover

v1.107.3
2026-05-11 10:49:43 +02:00
85912beb4b style: AnyDay tabs — white background on all tabs, rounded-top browser-tab style
Inactive: white bg, subtle border, rounded top corners (6px 6px 0 0)
Active: same white bg + 3px accent-colored bottom border
Tab bar: align-items flex-end + 1px border-bottom so tabs sit flush

v1.107.2
2026-05-11 10:26:10 +02:00
50aa310a2d feat(design): AnyDay area polish — tabs, cards, flyout menu
CSS:
- .weekly-someday padding: 0 (no left/right border bleed)
- .someday-tabs-bar: removed border-bottom + bg, padding 0 12px
- Tabs: underline-style (border-bottom: 3px accent on active), transparent
  bg — active tab visually merges with someday area
- Active count badge: accent-tinted instead of white-on-blue
- .weekly-someday-list: 300px wide (restored from 240px)
- .task-list-slot: min-height instead of fixed height + top/bottom padding
  so two-line tasks expand properly instead of being clipped

JSX:
- Combined provider icon + tab-assign + edit-style + delete into a single
  flyout menu per card header (trigger = provider icon if synced, else pencil)
- Flyout: "Open in Google/Apple/…", "Assign to tab", "Edit style", "Delete"
- Added menuOpenListId state to track open flyout

v1.107.1
2026-05-11 10:16:21 +02:00
f0df18c853 feat(design): redesign AnyDay/Someday area — pill tabs + card layout
Tabs:
- Pill-shaped tabs (border-radius: 20px) with proper background on inactive
- Inactive tabs now use readable dark text (--weekly-text) instead of
  barely-visible --weekly-text-light
- Active tab: accent color + white text + glow shadow (box-shadow)
- Count badges: larger, higher contrast, 700 font-weight
- Tab bar: taller padding (8px), thicker bottom border (2px)

Cards:
- Rounded corners (10px), border + subtle drop shadow
- Cards now 240px wide (was 300px) for better density
- Grid padding changed from padding-bottom only to full 12px padding
- List header has subtle tinted background + reduced height (48px)
- Title font tweaked to 0.85rem/700 (was 1.15rem/800) to match card size
- Add-task button padding aligned to card edges (12px horizontal)
- Removed p-2 Tailwind class that was double-padding the card

v1.107.0
2026-05-11 09:45:16 +02:00
16e51be26d fix: restore hover-fade on left header toolbar when in top-menu mode
Left section was missing opacity-0 group-hover:opacity-100 Tailwind classes
after the useLeftRail display:none change. Restored fade-in-on-hover
conditionally when top-toolbar mode is active.

v1.106.6
2026-05-11 09:36:11 +02:00
1bfbe4582f style: use FolderOpen icon for Projects in left rail and settings tab
v1.106.5
2026-05-11 09:33:24 +02:00
2f29355bb9 fix: header readability, icon consistency, settings menu position toggle, account tab
- Remove hover blur/opacity on header center section when left rail is active
  (group-hover:opacity-20 blur only applies in top-toolbar mode)
- Add min-height to desktop header so it doesn't collapse when both left/right
  sections are hidden in left-rail mode
- User icon in left rail now opens Settings on the Account tab directly
- Add Menu Position toggle (Left/Top) to Settings > General tab so users
  can find and change toolbar layout from the settings menu
- Replace FolderPlus with Briefcase icon for Projects in left rail to match
  the Settings sidebar's Projects tab icon

v1.106.4
2026-05-11 09:22:43 +02:00
9ef2b9fe5f fix: hide full header toolbar when left rail is active; fix user menu in left rail
- Right header section now also gets display:none when useLeftRail is true,
  so neither the left nor right header controls are visible in left-rail mode
- Replaced UserMenu (which positions from the right edge) with an inline
  dropdown in the left rail that opens to the right of the rail at left:52px,
  correctly positioned and functional
- Added LogOut icon to lucide imports

v1.106.3
2026-05-11 09:10:26 +02:00
9104625353 fix: true mutual exclusion for left rail vs header toolbar; fix rail bottom icons
1. Header left-section (view switcher, days, slot) is now display:none when
   in left-rail mode — previously it was opacity-0 on hover so both were
   visible simultaneously.

2. Left rail bottom now has two distinct buttons:
   - ⚙ Settings (cogwheel) → opens full SettingsSidebar
   - 👤 User → opens UserMenu (same as header avatar, with profile/sign-out)
   Previously a single User icon opened the settings instead.

v1.106.2
2026-05-11 08:56:31 +02:00
0e09711432 feat: menu position setting — left rail or top toolbar, not both
Add a "Menüposition / Menu position" segmented toggle (Left / Top) in
the expanded left-rail preferences panel and in the top-mode overlay.

- "Left" (default): persistent left rail; header toolbar controls are
  de-emphasised (fade in on hover only since they are duplicated).
- "Top": left rail hidden entirely; header controls always visible;
  PanelLeftOpen button in header opens the full settings overlay.

Persisted via saveSetting("menuPosition", …) so the choice survives
page reloads.

v1.106.1
2026-05-11 08:44:44 +02:00
c3dd70a438 feat(design): collapsible left rail toolbar with flyout menus
Replace the overlay quick-settings panel with a persistent fixed-left
toolbar on desktop. Collapsed (44 px) shows icon-only buttons; expanded
(240 px) shows the full labelled panel. Hovering a settings icon in
collapsed mode opens a flyout panel (view, columns, slot, visibility,
appearance). Mobile overlay behaviour is unchanged.

v1.106.0
2026-05-11 08:37:09 +02:00
754fa6ad25 feat(design): left rail (quick settings panel)
Switch panel background to --paper, all dividers to --line, section
labels to --ink-3, active view/days/slot buttons to --accent/--ink.
Soften panel shadow to 4% opacity. No logic, structure, or API changes.

v1.105.2
2026-05-10 14:10:18 +02:00
6f610ac669 feat(design): app shell + header + corner buttons
Trim desktop header height (py-2 → py-1.5) and switch border to --line
token. Update .weekly-header CSS class to use --line. Add baseline
letter-spacing: -0.005em to body. No logic, structure, or API changes.

v1.105.1
2026-05-10 14:08:26 +02:00
f54dde3689 feat(design): add v2 design tokens
Add --ink-*, --accent, --line, --bg, --paper, --tasks-bg, calendar-source
event palette, and all-day chip palette as CSS custom properties. Tokens
alias existing --weekly-* vars where a match exists so no existing styles
are affected. Also adds docs/design/ to .gitignore.

v1.105.0
2026-05-10 14:06:15 +02:00
ac19e13714 style: AnyDay "Add task..." reads as a real input field
Give the bottom-of-list "Add task..." input a framed background (white
with a subtle border in light mode, slate-800 with a darker border in
night mode) plus a small radius and padding, so it visually matches user
expectations of an input. The inline slot-editor that appears when you
click on an empty slot keeps the transparent look so it blends with the
surrounding task rows.

v1.104.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:58:48 +02:00
8958df03ce feat: focus-triggered MS To-Do delta sync for near-instant change reflection
Pull-sync now runs on visibilitychange + window focus (in addition to mount
and the slow 15-min safety-net interval), throttled to one call per 5s. The
moment the user returns to the tab the app reconciles, so an Outlook-side
checkbox/star/title change shows up within a second of refocusing the tab
instead of after a 15-minute wait.

To keep this cheap, the Microsoft Graph delta endpoint
(/me/todo/lists/{id}/tasks/delta) is used when we have a stored deltaToken
for a synced list — typically a few hundred bytes per call when nothing
changed. Tokens are persisted on SomedayList.syncDeltaToken (new column,
migration applied) and refreshed each call. On 410 Gone (token expired
>30 days) we fall back to a full fetch and prime a new chain. Delta also
brings remote deletions (@removed) so they propagate locally without
needing a full list scan.

v1.104.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:58:00 +02:00
f61f5ddb7a fix: pull-sync now reconciles per-field so Outlook star reflects locally
The previous timestamp gate (`if (remoteUpdated <= localUpdated) continue`)
hid genuine remote-side changes because every prior no-op pull bumped
lastSyncedAt to "now", making it newer than the Outlook lastModifiedDateTime
of a subsequent star toggle. Now the pull does a per-field comparison and
only writes lastSyncedAt when at least one field actually changed. Same fix
applied to the Google Tasks pull branch for consistency.

Also run a pull-sync once on mount instead of waiting up to 15 min, so
remote-side changes show up immediately on next page load.

v1.103.2

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:27:22 +02:00
bf2819ce67 fix: clear all cached series rows on recurring delete + render star for any important task
- deleteCachedEvent now removes every cached row that belongs to the same
  recurring series (master id + all "<masterId>::*" instances + rows whose
  recurringEventId matches the master), so deleting "all occurrences" no
  longer leaves orphan rows that resurface on the next read.
- getPriorityBadge: surface the yellow star whenever a task is marked
  important (e.g. a starred Outlook To-Do task pulled in via sync), even
  when the active priority style is Eisenhower / ABCDE / Ivy Lee.

v1.103.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:08:59 +02:00
42905641cd feat: bidirectional Outlook/MS To-Do sync, busyStatus + star, list-delete dialog
- Outlook recurring delete: also delete the specific instance after the master
  to clean up orphaned first occurrences.
- CachedCalendarEvent gains busyStatus column (migration applied) so showAs
  changes from Outlook flow back into the weekly view.
- Outlook create/update now round-trip the full event (busyStatus, visibility,
  attendees, reminders, recurrence link) via a shared response mapper.
- MS To-Do importance ('high' star) ⇄ local importance flag in pull-sync,
  push-sync, initial import, and local-task POST/PATCH; dueDateTime ⇄
  scheduledDate added to pull-sync.
- Local task PATCH now fires a best-effort push to Outlook/Google/Synology so
  any field change keeps both sides aligned.
- AnyDay list delete dialog now offers Cancel / hide locally / delete on both
  sides; new deleteMsTodoList + deleteGoogleTaskList helpers.

v1.103.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 19:53:09 +02:00
55cc066707 merge: dev into main with synced lockfile for v1.102.0 2026-05-02 09:54:46 +02:00
8c247e9115 chore: sync package-lock.json with v1.102.0
v1.102.0
2026-05-02 09:54:43 +02:00
6847e59701 merge: dev into main for v1.102.0 release 2026-05-02 09:50:28 +02:00
9ce9f4484a feat: priority icons toggle, projects sidebar overhaul, intro hints
- Add Show/Hide Priority Icons toggle in left settings panel under Project Icons
- Projects sidebar: move Add Project form to the top, add per-project
  filter button (filters tasks across all views), expand/collapse to show
  tasks underneath each project name, active-filter banner with clear
- Intro hints on first load: briefly reveal hidden header controls then
  fade out, and bounce-scroll the main grid to reveal off-screen tasks

v1.102.0
2026-05-02 09:50:26 +02:00
a040ba128b Merge branch 'main' into dev
# Conflicts:
#	package.json
#	tsconfig.tsbuildinfo
2026-05-02 01:01:44 +02:00
8c0f7acfd8 fix: searchable timezone, flag dropdown for language
- Bring back the dropdown UX for the language picker (the v1.99 button
  grid was nicer at first but lost the at-a-glance affordance the user
  preferred). New SearchableDropdown component renders a real dropdown
  trigger + list, with each option carrying its inline-SVG flag.
- Timezone picker is now searchable. Match against IANA name, code
  (CET, ET…), city/country label, and offset in multiple notations
  ("UTC+1", "+01:00", "GMT+01:00"). Used both for the user's primary
  timezone and the additional-timezones add picker.

v1.101.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 00:53:59 +02:00
d3a40e32a0 fix: localise auth emails, repair verify link, recover from expired
Wave-3 user feedback (points 1–3):
- Email templates: extract de/en/fr/es/it copy into emailTemplates.ts
  and pick the user's language. Signup forwards navigator.language and
  the API also respects Accept-Language; resend/reset reuse user.language.
- Verify link: the email button now points at the client page instead of
  the API GET handler, so corporate inbox scanners (M365 Safe Links etc.)
  can no longer pre-fetch and silently consume the one-shot token. The
  client page requires a real click before calling PATCH /api/auth/
  verify-email. The legacy GET handler now just redirects to the client
  page so old in-flight emails keep working.
- Code expired UX: when users land via an expired-link redirect we no
  longer leave them with a blank email field — the page exposes an email
  input so the resend button has something to act on. The verify-email
  redirect also forwards the email param.

Misc: tsconfig migrated to moduleResolution=bundler and dropped
downlevelIteration to clear the TS 7.0 deprecation errors.

v1.101.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:09:33 +02:00
ffe2f46c64 feat: flag icons, real timezone picker, multi-timezone display
Wave-2 user feedback (points 4–6):
- Language picker: replace emoji flags (which don't render reliably on
  Windows) with inline-SVG flag components for GB/DE/FR/ES/IT. Used
  in both the Settings localisation tab and the onboarding wizard.
- Timezone: replace the read-only Intl.DateTimeFormat display with a
  real <select> over a curated list of major IANA zones. Each option
  shows "(UTC±hh:mm) CODE — Region" and the list is sorted by current
  offset, then code, then IANA name.
- Multi-timezone: new ExtraTimezonesEditor in the localisation tab lets
  users pin additional zones (stored in user.viewSettings.extraTimezones
  to avoid a migration). The time-grid axis now renders the equivalent
  hour for each pinned zone next to the local label, helpful for
  scheduling across regions.

v1.100.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:00:53 +02:00
ab70733c24 fix: render priority badge in calendar/grid view
GridTaskBlock received showPriorityIcons + priorityStyle props but
never rendered the badge. Export getPriorityBadge from WeeklyView and
inject it before the project icon in the grid task title. Tasks
prioritised in the Eisenhower/ABCDE/Ivy Lee/Pareto picker now show
their indicator in the time-grid layout too.

v1.99.2

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 19:53:52 +02:00
291ff23859 icons for priority and colors for someday lists 2026-05-01 14:49:54 +02:00
d3fedc4d0d fix: someday list color tints background + apply pending migration
- color now renders as a tinted background (alpha 0x1f) plus a 4px
  accent border, matching the user's "background colorcoded" request.
- Migration 20260501 was unapplied locally; mark prior failed
  20260403_add_task_url as applied and deploy 20260501. Without it
  the API silently dropped color/icon and the SSE refetch wiped the
  optimistic update, so visuals never appeared.

v1.99.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:34:23 +02:00
52d160ccea feat: projects tab, priority icons, list/tab visuals
Wave-1 user feedback (points 7–10):
- Settings: new Projects tab with full CRUD (name, color, icon picker)
- Priority Icons render in simple/calendar/list views with style-specific
  visuals (Eisenhower icons, ABCDE letters, Ivy Lee 1–6, Pareto star).
  Toggle + style selector in Settings; Ivy Lee ranks now persist to
  task.priority so cross-view badges stay consistent.
- Someday Lists gain optional icon (left of title) and color (left
  border tint), edited via a pencil-popup with IconPicker + color picker.
- Tabs gain optional icon and color, stored in user.viewSettings JSON
  and edited via the same popup pattern.

Schema: SomedayList.color/icon, User.showPriorityIcons/priorityStyle.

v1.99.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:23:56 +02:00
3e19ebe85a fix: security hardening, Prisma singleton, tsconfig cleanup
- tsconfig: remove deprecated downlevelIteration (es2017 has native iterators),
  change moduleResolution from node to bundler
- auth: set allowDangerousEmailAccountLinking=false on all OAuth providers,
  restrict debug mode to development only
- prisma: replace new PrismaClient() with singleton import in 10 API routes
  to prevent connection pool exhaustion
- goal API: validate user-supplied quote URLs to block SSRF (private IPs,
  non-http(s) schemes)
- email: guard DEV verification/reset link console.logs behind NODE_ENV check

v1.98.0
2026-04-20 19:52:47 +02:00
aa4c6a085d fix: show rolling icon in simple and calendar views
Add persistent inline rolling indicator to GridTaskBlock so rolling tasks
are visually marked in simple and calendar (time-grid) views, matching
the badge already shown in list view.

v1.97.4
2026-04-20 19:39:14 +02:00
cf7e138bc9 fix: strip Apple Calendar icon escapes and emoji from PDF event titles
Apple Calendar encodes calendar icons as =h=g / =i=g escape sequences in
iCalendar SUMMARY fields during CalDAV sync.  Helvetica cannot render the
underlying glyphs, causing the raw escape bytes to appear verbatim in the
PDF.  Add pdfTitle() helper that strips these patterns along with emoji
and Apple private-use area characters before rendering any title text.
The calendar color chip/dot already provides the visual calendar identity.

v1.97.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:02:39 +02:00
57 changed files with 5079 additions and 986 deletions

6
.gitignore vendored
View File

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

20
CHANGELOG.md Normal file
View File

@ -0,0 +1,20 @@
# 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

@ -0,0 +1,12 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"files.associations": {
"*.css": "tailwindcss"
}
}
}

1
build.pid Normal file
View File

@ -0,0 +1 @@
2331610

27
clear-data.ts Normal file
View File

@ -0,0 +1,27 @@
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());

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.*

214
fill-data-v2.ts Normal file
View File

@ -0,0 +1,214 @@
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());

153
fill-data.ts Normal file
View File

@ -0,0 +1,153 @@
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());

118
fill-ext-events.js Normal file
View File

@ -0,0 +1,118 @@
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());

116
fill-ext-events.ts Normal file
View File

@ -0,0 +1,116 @@
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.92.0",
"version": "1.104.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-weekly-todo-list",
"version": "1.92.0",
"version": "1.104.1",
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",

View File

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

View File

@ -0,0 +1,7 @@
-- User: priority style + visibility toggle
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showPriorityIcons" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "priorityStyle" TEXT NOT NULL DEFAULT 'eisenhower';
-- SomedayList: per-list color and icon (used by Punkt 7 + 8)
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "color" TEXT;
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "icon" TEXT;

View File

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

View File

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

View File

@ -0,0 +1,3 @@
-- 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

@ -93,6 +93,8 @@ model User {
yearFontWeight String? @default("700")
showTaskCheckboxes Boolean @default(false)
showProjectIcons Boolean @default(false)
showPriorityIcons Boolean @default(true)
priorityStyle String @default("eisenhower")
weekStartDay Int @default(1)
emailVerificationCode String?
dayHeaderGap String? @default("0.75em")
@ -114,6 +116,8 @@ 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")
@ -226,11 +230,14 @@ model SomedayList {
title String
order Int @default(0)
tab String?
color String?
icon String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
externalId String?
externalProvider String?
lastSyncedAt DateTime?
syncDeltaToken String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@ -275,6 +282,7 @@ 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,6 +20,7 @@ export async function POST(request: NextRequest) {
id: true,
emailVerified: true,
emailVerificationExpires: true,
language: true,
},
});
@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
});
try {
await sendVerificationEmail(email, code, token);
await sendVerificationEmail(email, code, token, user.language);
} 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);
await sendPasswordResetEmail(email, resetToken, (user as any).language);
return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' });
}

View File

@ -1,14 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
import { PrismaClient } from '@prisma/client';
import { prisma } from '@/lib/prisma';
import { hash } from 'bcryptjs';
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
const prisma = new PrismaClient();
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
export async function POST(request: NextRequest) {
try {
const { name, email, password } = await request.json();
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'));
if (!email || !password) {
return NextResponse.json(
@ -42,11 +46,12 @@ export async function POST(request: NextRequest) {
emailVerificationCode: code,
emailVerificationToken: token,
emailVerificationExpires: expires,
language,
},
});
try {
await sendVerificationEmail(email, code, token);
await sendVerificationEmail(email, code, token, language);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
}
@ -77,6 +82,7 @@ export async function POST(request: NextRequest) {
name: name || undefined,
email,
passwordHash,
language,
emailVerificationCode: code,
emailVerificationToken: token,
emailVerificationExpires: expires,
@ -92,7 +98,7 @@ export async function POST(request: NextRequest) {
// Send verification email
try {
await sendVerificationEmail(email, code, token);
await sendVerificationEmail(email, code, token, language);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
// User is created but email failed — they can use "resend" later

View File

@ -78,31 +78,58 @@ export async function POST(request: NextRequest) {
}
}
// GET: Verify via one-click link
// 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.
export async function GET(request: NextRequest) {
try {
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();
if (!token) {
return NextResponse.redirect(
new URL('/auth/verify-email?error=missing_token', request.url)
);
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
}
const user = await prisma.user.findFirst({
where: {
emailVerificationToken: token,
emailVerificationExpires: { gt: new Date() },
},
select: {
id: true,
email: true,
emailVerified: true,
emailVerificationExpires: true,
},
});
// Friendly path: token already consumed but the email is verified — treat as success.
if (!user) {
return NextResponse.redirect(
new URL('/auth/verify-email?error=invalid_token', request.url)
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 }
);
}
// Verify the user
await prisma.user.update({
where: { id: user.id },
data: {
@ -114,13 +141,9 @@ export async function GET(request: NextRequest) {
},
});
return NextResponse.redirect(
new URL('/auth/login?verified=true', request.url)
);
return NextResponse.json({ message: 'Email verified successfully', email: user.email });
} catch (error) {
console.error('Token verification error:', error);
return NextResponse.redirect(
new URL('/auth/verify-email?error=server_error', request.url)
);
return NextResponse.json({ error: 'Failed to verify email' }, { status: 500 });
}
}

View File

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

View File

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

View File

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

View File

@ -127,7 +127,16 @@ export async function GET(req: Request) {
};
// Try each user-configured URL first
const userUrls: string[] = (user as any)?.quoteSourceUrls?.filter(Boolean) || [];
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);
for (const url of userUrls) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });

View File

@ -2,15 +2,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { prisma } from '@/lib/prisma';
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,11 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { prisma } from '@/lib/prisma';
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 },
@ -123,6 +121,8 @@ 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,6 +143,33 @@ 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 },
@ -164,7 +191,7 @@ export async function DELETE(request: NextRequest) {
});
notifyUser(userId, "list-changed", { action: "deleted" });
return NextResponse.json({ success: true });
return NextResponse.json({ success: true, externalDeleteFailed });
} catch (error) {
console.error('Error deleting someday list:', error);
return NextResponse.json(
@ -209,8 +236,8 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ success: true });
}
// Handle Single Update (Title and/or Tab)
const { id, title, tab } = body;
// Handle Single Update (Title, Tab, Color, Icon)
const { id, title, tab, color, icon } = body;
if (!id) {
return NextResponse.json(
@ -234,6 +261,8 @@ export async function PATCH(request: NextRequest) {
const data: Record<string, any> = {};
if (title !== undefined) data.title = title;
if (tab !== undefined) data.tab = tab;
if (color !== undefined) data.color = color || null;
if (icon !== undefined) data.icon = icon || null;
const list = await prisma.somedayList.update({
where: { id },

View File

@ -2,13 +2,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { prisma } from '@/lib/prisma';
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;
@ -23,6 +21,7 @@ interface ImportedTask {
status: string;
sourceListTitle: string;
parentExternalId?: string;
important?: boolean;
}
export async function POST(req: NextRequest) {
@ -128,6 +127,7 @@ 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,6 +295,7 @@ 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);
@ -313,7 +314,8 @@ export async function POST(req: NextRequest) {
externalId: task.externalId,
externalProvider: provider,
externalListId: task.externalListId,
lastSyncedAt: new Date()
lastSyncedAt: new Date(),
...(task.important ? { importance: true } : {}),
}
});
externalToLocalId.set(task.externalId, newTask.id);

View File

@ -1,15 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { prisma } from '@/lib/prisma';
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,11 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient, Task } from '@prisma/client';
import { Task } from '@prisma/client';
import { prisma } from '@/lib/prisma';
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;
@ -25,6 +24,71 @@ 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> = {
@ -309,7 +373,12 @@ 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 });
const msTask = await createMsTodoTask(accessToken, somedayList.externalId, {
title,
body: description || undefined,
dueDateTime: scheduledDate || undefined,
importance: importance ? 'high' : undefined,
});
externalId = msTask.id;
externalProvider = 'outlook';
externalListId = somedayList.externalId;
@ -510,6 +579,21 @@ 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, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { fetchMsTodoTasksForSync, fetchMsTodoTasksDelta, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
// GET - Pull changes from Google Tasks into local DB
@ -144,11 +144,8 @@ export async function GET(req: NextRequest) {
continue;
}
const remoteUpdated = new Date(remote.updated);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) continue;
const updateData: any = { lastSyncedAt: new Date() };
// Field-by-field reconciliation (see Outlook block for rationale).
const updateData: any = {};
const remoteCompleted = remote.status === 'completed';
if (remoteCompleted !== localTask.completed) {
@ -173,17 +170,13 @@ export async function GET(req: NextRequest) {
updateData.parentTaskId = null;
}
if (Object.keys(updateData).length > 1) {
if (Object.keys(updateData).length > 0) {
updateData.lastSyncedAt = new Date();
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
}
}
@ -275,31 +268,110 @@ 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 remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
const syncedListRow = syncedListIdToRow.get(listId);
const previousDeltaToken = syncedListRow?.syncDeltaToken ?? null;
// 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;
}
}
}
const remoteUpdated = new Date(remote.lastModifiedDateTime);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) 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 updateData: any = { lastSyncedAt: new Date() };
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 remoteCompleted = isMsTodoTaskCompleted(remote.status);
if (remoteCompleted !== localTask.completed) {
@ -315,24 +387,36 @@ export async function GET(req: NextRequest) {
updateData.description = remoteNotes;
}
if (Object.keys(updateData).length > 1) {
// 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();
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 not yet in local DB
const somedayListInfo = outlookListIdToSomedayList.get(listId);
if (somedayListInfo) {
const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id));
// -- Create new local tasks for remote tasks we don't have yet --
if (syncedListRow) {
const newRemoteTasks = reconcileTargets.filter((rt: any) => !existingExternalIds.has(rt.id));
for (const remote of newRemoteTasks) {
if (!remote.title || !remote.title.trim()) continue;
@ -343,7 +427,11 @@ export async function GET(req: NextRequest) {
title: remote.title,
description: remote.body?.content || null,
completed: isMsTodoTaskCompleted(remote.status),
somedayListId: somedayListInfo.id,
importance: remote.importance === 'high' ? true : null,
scheduledDate: remote.dueDateTime?.dateTime
? new Date(remote.dueDateTime.dateTime)
: null,
somedayListId: syncedListRow.id,
externalId: remote.id,
externalProvider: 'outlook',
externalListId: listId,
@ -354,6 +442,14 @@ 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);
}
@ -484,7 +580,7 @@ export async function PATCH(req: NextRequest) {
}
const body = await req.json();
const { taskId, completed, title, action, scheduledDate, notes } = body;
const { taskId, completed, title, action, scheduledDate, notes, importance } = body;
if (!taskId) {
return NextResponse.json({ error: 'Task ID required' }, { status: 400 });
@ -538,13 +634,16 @@ 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 } = {};
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null; importance?: 'low' | 'normal' | 'high' } = {};
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);
}
@ -658,6 +757,7 @@ 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,6 +114,22 @@ 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`;
}
@ -454,7 +470,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 } }, (t.title || '').slice(0, 36)));
}, React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT } }, pdfTitle(t.title, 36)));
}
if (t.isExternal) {
// Calendar event: solid color chip with white text + dot indicator (matches webapp style)
@ -473,7 +489,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 } }, (t.title || '').slice(0, 36)),
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: evTextColor, flex: 1 } }, pdfTitle(t.title, 36)),
);
}
// User-created all-day task: left border only, no background fill
@ -481,7 +497,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' } }, (t.title || '').slice(0, 36)));
}, React.createElement(Text, { style: { fontSize: 7.5, color: '#374151' } }, pdfTitle(t.title, 36)));
}),
allDayTasks.length > 5 ? React.createElement(Text, {
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
@ -575,7 +591,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' } }, (t.title || '').slice(0, 60)),
React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT, lineHeight: 1.3, textDecoration: 'line-through' } }, pdfTitle(t.title)),
);
}
@ -589,7 +605,7 @@ function WeekCalendarPDF({
paddingLeft: 3, paddingVertical: 1, marginBottom: 1,
},
},
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, pdfTitle(t.title)),
);
}
@ -601,7 +617,7 @@ function WeekCalendarPDF({
paddingVertical: 1, marginBottom: 1,
},
},
React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, pdfTitle(t.title)),
);
}),
);
@ -719,7 +735,7 @@ function WeekCalendarPDF({
}),
React.createElement(Text, {
style: { fontSize: 7, color: '#374151', lineHeight: 1.3, flex: 1 },
}, (t.title || '').slice(0, 52)),
}, pdfTitle(t.title)),
)
),
list.tasks.length > 18 ? React.createElement(Text, {

View File

@ -34,6 +34,8 @@ export async function GET(request: NextRequest) {
showSchedule: true,
showTaskCheckboxes: true,
showProjectIcons: true,
showPriorityIcons: true,
priorityStyle: true,
cellDuration: true,
viewStyle: true,
viewDays: true,
@ -109,6 +111,8 @@ export async function GET(request: NextRequest) {
weatherLocation: true,
weatherRecentCities: true,
viewSettings: true,
customCssVars: true,
customCss: true,
hasCompletedOnboarding: true,
createdAt: true
}
@ -150,10 +154,11 @@ export async function PATCH(request: NextRequest) {
hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
showTaskCheckboxes, showProjectIcons, dayHeaderGap,
showTaskCheckboxes, showProjectIcons, showPriorityIcons, priorityStyle, dayHeaderGap,
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages,
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
customCssVars, customCss,
hasCompletedOnboarding
} = body;
@ -178,6 +183,8 @@ export async function PATCH(request: NextRequest) {
...(showSchedule !== undefined && { showSchedule }),
...(showTaskCheckboxes !== undefined && { showTaskCheckboxes }),
...(showProjectIcons !== undefined && { showProjectIcons }),
...(showPriorityIcons !== undefined && { showPriorityIcons }),
...(priorityStyle !== undefined && { priorityStyle }),
...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }),
...(viewStyle !== undefined && { viewStyle }),
...(viewDays !== undefined && !isNaN(viewDays) && { viewDays }),
@ -252,6 +259,8 @@ 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() !== "") {
@ -283,6 +292,8 @@ export async function PATCH(request: NextRequest) {
showSchedule: true,
showTaskCheckboxes: true,
showProjectIcons: true,
showPriorityIcons: true,
priorityStyle: true,
cellDuration: true,
viewStyle: true,
viewDays: true,
@ -358,6 +369,8 @@ export async function PATCH(request: NextRequest) {
weatherLocation: true,
weatherRecentCities: true,
viewSettings: true,
customCssVars: true,
customCss: true,
hasCompletedOnboarding: true,
}
});

View File

@ -34,10 +34,11 @@ 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 }),
body: JSON.stringify({ name, email, password, language }),
});
const data = await response.json();

View File

@ -7,13 +7,16 @@ import Link from 'next/link';
function VerifyEmailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get('email') || '';
const initialEmail = searchParams.get('email') || '';
const tokenFromUrl = searchParams.get('token') || '';
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(() => {
@ -26,10 +29,38 @@ function VerifyEmailContent() {
}
}, [error]);
// Auto-focus first input
// Auto-focus first input (only if we're not in token-link mode)
useEffect(() => {
inputRefs.current[0]?.focus();
}, []);
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);
}
};
const handleInput = (index: number, value: string) => {
// Handle paste of full code
@ -140,9 +171,15 @@ 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' }}>
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email}</strong>
Click the button below to confirm{email ? <> <strong style={{ color: '#667eea' }}>{email}</strong></> : ' your email'}.
</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 && (
@ -165,6 +202,38 @@ 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,6 +32,7 @@ body {
background-color: #f8fafc;
color: #1e293b;
line-height: 1.6;
letter-spacing: -0.005em;
transition: background-color 0.3s ease;
}
@ -474,6 +475,46 @@ 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 */
@ -492,7 +533,7 @@ h3 {
top: 0;
z-index: 100;
background: var(--weekly-bg);
border-bottom: 1px solid var(--weekly-border);
border-bottom: 1px solid var(--line);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
@ -1237,7 +1278,7 @@ h3 {
.weekly-someday {
background-color: #f9f9f9;
width: 100%;
padding: 0 1rem;
padding: 0;
position: relative;
flex-shrink: 0;
}
@ -1319,14 +1360,15 @@ h3 {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: 1.5rem;
gap: 12px;
width: max-content;
min-width: 100%;
padding-bottom: 1rem;
padding: 12px 12px 16px;
align-items: flex-start;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overflow: visible; /* Let parent container handle scrolling */
overflow: visible;
background-color: #F9F9F9;
}
/* Scrollbar Styling for Someday Container */
@ -1372,37 +1414,45 @@ h3 {
display: none;
}
/* Ruled paper lines for someday tasks */
/* Someday list cards */
.weekly-someday-list {
padding: 0;
min-height: 200px;
flex: 0 0 300px; /* Slightly wider lists */
flex: 0 0 300px;
width: 300px;
transition: transform 0.2s ease, opacity 0.2s ease;
transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
max-width: 100%;
display: flex;
flex-direction: column;
overflow-y: visible;
scrollbar-width: none; /* Hide scrollbar Firefox */
-ms-overflow-style: none; /* Hide scrollbar IE/Edge */
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;
}
.weekly-someday-list::-webkit-scrollbar {
display: none; /* Hide scrollbar Chrome/Safari/Webkit */
display: none;
}
.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 {
@ -1463,7 +1513,7 @@ h3 {
}
.weekly-someday-list:last-child {
border-right: none;
/* gap handles spacing, no extra right border needed */
}
.weekly-someday-list.is-dragging {
@ -1473,9 +1523,9 @@ h3 {
.weekly-someday-list .weekly-task-item {
border-bottom: none;
min-height: 38px;
min-height: 24px;
height: auto;
padding: 6px 1rem;
padding: 4px 4px;
display: flex;
align-items: flex-start;
overflow: visible !important;
@ -1495,36 +1545,38 @@ h3 {
overflow: visible;
white-space: normal;
text-overflow: clip;
padding: 0;
padding-left: 4px;
}
.weekly-someday-list-title-header {
padding: 0 1rem;
padding: 10px 12px;
margin-bottom: 0;
height: 56px;
min-height: 56px;
min-height: 48px;
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: 1.15rem;
font-weight: 800;
letter-spacing: 0.08em;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--weekly-text, #222);
background: transparent;
border: none;
flex: 1;
min-width: 0;
padding: 4px 0;
padding: 2px 0;
outline: none;
transition: opacity 0.15s ease;
text-transform: uppercase;
}
.weekly-someday .weekly-task-item {
@ -1542,29 +1594,29 @@ h3 {
.someday-add-task-btn {
background: none;
border: none;
color: #aaa;
color: #bbb;
cursor: pointer;
font-size: 0.85rem;
padding: 4px 0;
font-size: 0.8rem;
padding: 8px 12px;
text-align: left;
width: 100%;
transition: color 0.15s ease;
}
.someday-add-task-btn:hover {
color: #666;
color: #888;
}
/* Someday Tabs Bar (horizontal above grid) */
.someday-tabs-bar {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-end;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid var(--weekly-border);
padding: 0px 12px 0;
overflow-x: auto;
scrollbar-width: none;
background-color: #EEEEEE;
}
.someday-tabs-bar::-webkit-scrollbar {
@ -1576,13 +1628,14 @@ h3 {
border: none;
cursor: pointer;
color: var(--weekly-text-light, #999);
padding: 3px;
border-radius: 4px;
padding: 5px;
border-radius: 6px;
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 {
@ -1592,60 +1645,69 @@ h3 {
.someday-tabs-bar-divider {
width: 1px;
height: 16px;
height: 34px;
background: var(--weekly-border, #e5e7eb);
flex-shrink: 0;
margin: 0 2px;
margin: 0 4px;
}
/* Someday Tab Buttons (horizontal) */
/* Someday Tab Buttons (horizontal) — pill + underline style */
.someday-tab-btn-h {
background: none;
border: none;
cursor: pointer;
color: var(--weekly-text-light, #999);
font-size: 0.75rem;
color: var(--weekly-text-light, #888);
font-size: 0.8rem;
font-weight: 500;
padding: 3px 10px;
border-radius: 3px;
padding: 5px 12px 4px;
border-radius: 6px 6px 0 0;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 4px;
}
.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: var(--weekly-accent, #6366f1);
color: #fff;
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;
}
.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: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.08));
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: rgba(0, 0, 0, 0.07);
font-size: 0.65rem;
font-weight: 600;
font-weight: 700;
line-height: 1;
margin-left: 2px;
color: var(--weekly-text-light, #888);
}
.someday-tab-btn-h.active .someday-tab-count {
background: rgba(255, 255, 255, 0.25);
color: #fff;
background: rgba(99, 102, 241, 0.12);
color: var(--weekly-accent, #000);
}
/* Someday Tab Wrapper with dissolve button */
@ -1658,7 +1720,7 @@ h3 {
.someday-tab-dissolve-h {
position: absolute;
right: -4px;
top: -4px;
top: -6px;
background: var(--weekly-bg, #fff);
border: 1px solid var(--weekly-border, #e5e7eb);
border-radius: 50%;
@ -2893,7 +2955,7 @@ h3 {
}
.time-slot-label span {
transform: translateY(-50%); /* Centers the label on the grid line */
transform: translateY(0%); /* Centers the label on the grid line */
background: var(--weekly-bg);
padding: 0 4px;
z-index: 1;
@ -3208,26 +3270,29 @@ h3 {
/* Resize handle for draggable section borders */
.resize-handle {
height: 7px;
height: 2px;
border: 1px solid #ccc;
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: 10;
z-index: 50;
flex-shrink: 0;
border-top: 1px solid var(--weekly-border, #e5e7eb);
border-bottom: 1px solid var(--weekly-border, #e5e7eb);
overflow: visible;
background-color: #ddd;
}
.resize-handle:hover {
background: rgba(59, 130, 246, 0.08);
border-color: var(--weekly-accent, #3b82f6);
background-color: #d7d7d7;
border-color: #b8b8b8;
}
.resize-handle:active {
background: rgba(59, 130, 246, 0.12);
border-color: var(--weekly-accent, #3b82f6);
background-color: #d1d1d1;
border-color: #aaa;
}
.resize-handle-bar {
width: 32px;
@ -3237,10 +3302,50 @@ 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 {
background: var(--weekly-accent, #3b82f6);
opacity: 0.5;
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;
}
.all-day-events-header {
@ -4729,6 +4834,42 @@ 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 } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity } from 'lucide-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, Check } 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_OPTIONS = [
const REMINDER_PRESETS = [
{ label: 'None', value: -1 },
{ label: 'At time of event', value: 0 },
{ label: '5 minutes before', value: 5 },
@ -40,6 +40,25 @@ const REMINDER_OPTIONS = [
{ 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' },
@ -63,6 +82,10 @@ 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>;
@ -76,6 +99,10 @@ export default function CalendarEventModal({
connections,
weekStartDay = 0,
language = 'en',
savedLocations = [],
onSaveLocation,
customReminderMinutes = [],
onSaveCustomReminder,
onClose,
onSave,
onDelete
@ -167,6 +194,53 @@ 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) => {
@ -234,7 +308,7 @@ export default function CalendarEventModal({
end: { dateTime: endDate.toISOString() },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
reminders: activeReminders.length > 0 ? activeReminders : undefined,
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
busyStatus: busyStatus,
visibility: visibility !== 'default' ? visibility : undefined,
attendees: attendees.length > 0 ? attendees : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
@ -261,6 +335,11 @@ 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 {
@ -348,11 +427,29 @@ 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 = () => {
@ -687,24 +784,61 @@ export default function CalendarEventModal({
</div>
)}
{/* Location */}
<div style={iconRow}>
{/* Location with autocomplete */}
<div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
<input
type="text"
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'}
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 */}
{/* Alert / Reminders */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
@ -714,24 +848,80 @@ export default function CalendarEventModal({
</button>
) : (
reminders.map((reminder, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<select
value={reminder.minutes}
value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))}
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
style={{ ...inlineSelect, flex: 1 }}
>
{REMINDER_OPTIONS.map(opt => (
{buildReminderOptions().map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
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

@ -0,0 +1,94 @@
"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,13 +1,13 @@
import React, { useState, useRef, useEffect, Suspense } from "react";
import dynamic from "next/dynamic";
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
import { Repeat, Circle, X, ChevronDown, Link } from "lucide-react";
import { Repeat, 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";
import MdiIcon from "@mdi/react";
import { allIcons } from "./iconRegistry";
import { Task, KanbanStage } from "./WeeklyView";
import { Task, KanbanStage, getPriorityBadge } from "./WeeklyView";
interface GridTaskBlockProps {
task: Task;
@ -39,6 +39,8 @@ interface GridTaskBlockProps {
workingHoursStart: number;
showTaskCheckboxes?: boolean;
showProjectIcons?: boolean;
showPriorityIcons?: boolean;
priorityStyle?: string;
projects?: any[];
onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
@ -76,6 +78,8 @@ export function GridTaskBlock({
workingHoursStart,
showTaskCheckboxes,
showProjectIcons,
showPriorityIcons = true,
priorityStyle = "eisenhower",
projects,
onProjectAssign,
kanbanStages = [],
@ -263,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 (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none";
return `4px solid transparent`;
})(),
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
padding: (task.kanbanStage && kanbanStages.some(s => s.id === task.kanbanStage)) || task.project?.color ? "2px 4px 2px 8px" : "2px 4px",
padding: weatherEnabled ? "2px 30px 2px 8px" : "2px 4px 2px 8px",
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
display: "flex",
flexDirection: "column",
@ -295,7 +299,7 @@ export function GridTaskBlock({
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 ? (
<form
onSubmit={(e) => {
@ -325,7 +329,7 @@ export function GridTaskBlock({
<span
style={{
display: "flex",
alignItems: "flex-start",
alignItems: "center",
gap: "3px",
overflow: "visible",
whiteSpace: "pre-wrap",
@ -337,6 +341,37 @@ 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"
@ -345,26 +380,11 @@ 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", 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={{ 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>
<span style={{ marginRight: "4px" }}>{task.title}</span>
{/* Subtask indicator */}
{task.subTasks && task.subTasks.length > 0 && (() => {
const completed = task.subTasks.filter(s => s.completed).length;
@ -476,53 +496,42 @@ export function GridTaskBlock({
</svg>
</button>
)}
{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
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;
{editingTaskId !== task.id && (() => {
const rolling = task.isRolling && !task.completed;
const provider = task.externalProvider || (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!rolling && !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
aria-label={`Synced with ${info.label}`}
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
>
<span className="flex-shrink-0" aria-label={`Synced with ${info.label}`} style={{ display: "inline-flex", alignItems: "center", opacity: 0.55 }}>
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
</span>
);
})()}
</div>
<div
className="task-actions"
@ -606,13 +615,21 @@ export function GridTaskBlock({
aria-expanded={showProjectPicker}
aria-haspopup="listbox"
>
<Circle
size={12}
aria-hidden="true"
fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
/>
{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" }} />}
</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" }}>
@ -638,7 +655,21 @@ export function GridTaskBlock({
setShowProjectPicker(false);
}}
>
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
{(() => {
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" }} />;
})()}
{p.name}
</button>
))}

View File

@ -5,6 +5,7 @@ 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;
@ -755,7 +756,7 @@ export default function OnboardingWizard({
transition: "all 0.15s",
}}
>
<span style={{ fontWeight: 700, fontSize: "0.75rem", background: darkMode ? "#374151" : "#e5e7eb", padding: "2px 6px", borderRadius: "4px" }}>{lang.flag}</span>
<FlagIcon code={lang.code} width={22} height={16} />
{lang.label}
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
</button>

View File

@ -70,6 +70,8 @@ interface PriorityViewProps {
darkMode: boolean;
language?: string;
onUpdateTask: (id: string, fields: Partial<PriorityTask>) => Promise<void>;
initialMethod?: PriorityMethod;
onMethodChange?: (m: PriorityMethod) => void;
}
const ABCDE_LABELS: Record<string, { label: string; desc: string; color: string; bg: string }> = {
@ -123,8 +125,14 @@ export default function PriorityView({
darkMode,
language = "en",
onUpdateTask,
initialMethod,
onMethodChange,
}: PriorityViewProps) {
const [method, setMethod] = useState<PriorityMethod>("eisenhower");
const [method, setMethodState] = useState<PriorityMethod>(initialMethod || "eisenhower");
const setMethod = useCallback((m: PriorityMethod) => {
setMethodState(m);
onMethodChange?.(m);
}, [onMethodChange]);
const [filterProject, setFilterProject] = useState("");
const [filterList, setFilterList] = useState("");
const [filterTimespan, setFilterTimespan] = useState("all");
@ -135,7 +143,15 @@ export default function PriorityView({
const [delegateTo, setDelegateTo] = useState("");
const [delegateNote, setDelegateNote] = useState("");
const [delegateType, setDelegateType] = useState<"person" | "ai">("person");
const [ivyLeeSelected, setIvyLeeSelected] = useState<Set<string>>(new Set());
const [ivyLeeSelected, setIvyLeeSelected] = useState<Set<string>>(() => {
// Hydrate from any task that already has a numeric priority "1"-"6"
const init = new Set<string>();
const ranked = tasks
.filter((t) => t.priority && /^[1-6]$/.test(t.priority))
.sort((a, b) => Number(a.priority) - Number(b.priority));
for (const t of ranked) init.add(t.id);
return init;
});
const [showFilters, setShowFilters] = useState(false);
const [isMobile, setIsMobile] = useState(false);
@ -251,12 +267,21 @@ export default function PriorityView({
const toggleIvyLee = useCallback((id: string) => {
setIvyLeeSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); return next; }
if (next.size >= 6) return prev; // max 6
const removing = next.has(id);
if (removing) {
next.delete(id);
onUpdateTask(id, { priority: null });
} else {
if (next.size >= 6) return prev;
next.add(id);
}
// Re-rank all selected tasks 1..N so cross-view badges stay consistent
Array.from(next).forEach((tid, idx) => {
onUpdateTask(tid, { priority: String(idx + 1) });
});
return next;
});
}, []);
}, [onUpdateTask]);
const openDelegate = useCallback((task: PriorityTask) => {
setDelegateModal(task);

View File

@ -1,5 +1,5 @@
"use client";
import { X, Type, Space, CheckSquare, Calendar, Minus } from "lucide-react";
import { X, Type, Space, CheckSquare, Calendar, Minus, Target } from "lucide-react";
interface QuickSettingsProps {
fontSize: string;
@ -12,6 +12,8 @@ interface QuickSettingsProps {
onStartDayOffsetChange: (offset: number) => void;
showLines: boolean;
onShowLinesChange: (show: boolean) => void;
showPriorityIcons?: boolean;
onShowPriorityIconsChange?: (show: boolean) => void;
isOpen: boolean;
onClose: () => void;
darkMode?: boolean;
@ -28,6 +30,8 @@ export default function QuickSettingsSidebar({
onStartDayOffsetChange,
showLines,
onShowLinesChange,
showPriorityIcons,
onShowPriorityIconsChange,
isOpen,
onClose,
darkMode,
@ -198,6 +202,17 @@ export default function QuickSettingsSidebar({
</div>
<Toggle checked={showLines} onChange={onShowLinesChange} />
</div>
{/* Show Priority Icons */}
{onShowPriorityIconsChange && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Target size={14} style={{ color: labelColor }} />
<span style={{ fontSize: "0.75rem", color: labelColor }}>Priority Icons</span>
</div>
<Toggle checked={showPriorityIcons ?? true} onChange={onShowPriorityIconsChange} />
</div>
)}
</div>
</div>
</>

View File

@ -0,0 +1,201 @@
"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

@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"
import { signOut } from "next-auth/react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { faServer, faFolder } from "@fortawesome/free-solid-svg-icons";
import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel";
import { ViewStyle, KanbanStage, Task } from "./WeeklyView";
import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants";
@ -13,14 +13,18 @@ import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewCo
import { EXPORT_FIELDS, type ExportFieldKey } from "../app/api/user/export/route";
import {
ArrowLeftRight,
Briefcase,
Calendar,
CalendarDays,
Check,
FolderOpen,
Globe,
Info,
Kanban,
Link,
ListTodo,
Palette,
Pencil,
Play,
Plus,
Settings,
@ -28,6 +32,376 @@ import {
Trash2,
User,
} from "lucide-react";
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 }) {
if (!icon) return <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />;
const normalised = icon.startsWith("fa") && icon.length > 2 && icon[2] === icon[2].toUpperCase()
? icon.slice(2, 3).toLowerCase() + icon.slice(3)
: icon;
const found = allIcons.find((i) => i.name === normalised || i.name === icon);
if (found) {
if (found.type === "fa") {
return <FontAwesomeIcon icon={found.icon as any} style={{ fontSize: size, color }} />;
}
return <Icon path={found.icon as string} size={size / 24} color={color} />;
}
return <span style={{ fontSize: size, lineHeight: 1 }}>{icon}</span>;
}
export interface SomedayList {
id: string;
@ -193,7 +567,7 @@ interface SettingsSidebarProps {
fetchAvailableTaskLists: (
provider: "google" | "apple" | "outlook" | "synology",
) => Promise<void>;
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "sync";
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "sync" | "projects";
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectsChanged: () => void;
kanbanStages: KanbanStage[];
@ -326,7 +700,7 @@ function SettingsSidebar({
onRunSetupAssistant,
}: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync"
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync" | "projects"
>(initialTab || "general");
const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
@ -819,11 +1193,11 @@ function SettingsSidebar({
className="weekly-settings-tabs"
style={{
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: "4px",
justifyContent: "space-around",
flexWrap: "nowrap",
gap: "0",
borderBottom: "1px solid var(--weekly-border, #eee)",
padding: "0 24px",
padding: "0",
}}
>
{([
@ -831,6 +1205,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: "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 },
@ -842,7 +1217,7 @@ function SettingsSidebar({
title={tab.label}
className="settings-tab-btn"
style={{
padding: "10px 14px",
padding: "10px 8px",
borderBottom:
activeTab === tab.key
? "2px solid var(--weekly-text, black)"
@ -880,6 +1255,74 @@ 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)" }}>
@ -1365,6 +1808,37 @@ function SettingsSidebar({
<label htmlFor="showProjectIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t.showProjectIcons}</label>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="checkbox" id="showPriorityIcons"
checked={profile.showPriorityIcons !== false}
onChange={(e) => {
saveField("showPriorityIcons", e.target.checked);
perView.saveViewSetting("showPriorityIcons", e.target.checked, false);
}}
style={{ width: "16px", height: "16px" }} />
<label htmlFor="showPriorityIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
{profile.language === "de" ? "Prioritäts-Icons anzeigen" : "Show Priority Icons"}
</label>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<label htmlFor="priorityStyle" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
{profile.language === "de" ? "Prioritäts-Stil" : "Priority Style"}
</label>
<select
id="priorityStyle"
value={profile.priorityStyle || "eisenhower"}
onChange={(e) => saveField("priorityStyle", e.target.value)}
className="weekly-input"
style={{ padding: "4px 8px", fontSize: "0.85rem", borderRadius: "6px", border: "1px solid var(--weekly-border, #e5e7eb)" }}
>
<option value="eisenhower">Eisenhower</option>
<option value="abcde">ABCDE</option>
<option value="ivylee">Ivy Lee</option>
<option value="pareto">80/20 (Pareto)</option>
</select>
</div>
{viewStyle !== "kanban" && (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="checkbox" id="protectEventTimes" checked={profile.protectEventTimes || false}
@ -1543,23 +2017,17 @@ function SettingsSidebar({
>
{t.language}
</label>
<select
<SearchableDropdown
value={profile.language || "de"}
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>
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} /> },
]}
/>
</div>
<div>
@ -1573,19 +2041,37 @@ function SettingsSidebar({
>
{t.timezone}
</label>
<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>
<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>
<div>
@ -3468,6 +3954,55 @@ 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" ? (
@ -3928,6 +4463,165 @@ function SettingsSidebar({
connections={connections}
t={t}
/>
) : activeTab === "projects" ? (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<div>
<h3 style={{ fontSize: "1rem", fontWeight: 600, margin: 0, display: "flex", alignItems: "center", gap: "8px" }}>
<FolderOpen size={18} /> {t.projects || "Projects"}
</h3>
<p style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)", marginTop: "4px" }}>
{t.projectsDesc || "Organize tasks with color-coded projects"}
</p>
</div>
{/* Existing projects list */}
{projects.length === 0 ? (
<div style={{ textAlign: "center", padding: "24px 12px", border: "1px dashed var(--weekly-border, #e5e7eb)", borderRadius: "10px" }}>
<ProjectIcon icon="folder" size={28} color="#aaa" />
<p style={{ fontSize: "0.85rem", color: "#aaa", fontStyle: "italic", marginTop: "8px" }}>
{t.noProjects || "No projects yet"}
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{projects.map((p) => (
<div key={p.id} style={{
display: "flex", alignItems: "center", gap: "10px",
padding: "10px 14px", borderRadius: "10px",
background: "var(--weekly-bg-soft, #f9fafb)",
borderLeft: `4px solid ${p.color || "#999"}`,
}}>
{editingProjectId === p.id ? (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)} style={{ width: "38px", height: "38px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-bg, #fff)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={editProjectIcon} size={16} color="#555" />
</button>
{showEditProjectIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={editProjectIcon} onSelect={(name) => { setEditProjectIcon(name); setShowEditProjectIconPicker(false); }} darkMode={false} />
</div>
)}
</div>
<input
type="text"
value={editProjectName}
onChange={(e) => setEditProjectName(e.target.value)}
className="weekly-input"
style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }}
onKeyDown={(e) => {
if (e.key === "Enter") {
fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) })
.then(() => { onProjectsChanged(); setEditingProjectId(null); });
}
if (e.key === "Escape") setEditingProjectId(null);
}}
autoFocus
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="color" value={editProjectColor} onChange={(e) => setEditProjectColor(e.target.value)} style={{ width: "30px", height: "30px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} />
<span style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
<div style={{ flex: 1 }} />
<button onClick={() => setEditingProjectId(null)} style={{ padding: "6px 12px", fontSize: "0.8rem", background: "none", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "8px", cursor: "pointer" }}>
{profile.language === "de" ? "Abbrechen" : "Cancel"}
</button>
<button
onClick={() => {
fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) })
.then(() => { onProjectsChanged(); setEditingProjectId(null); });
}}
className="weekly-btn-primary"
style={{ padding: "6px 12px", fontSize: "0.8rem", display: "inline-flex", alignItems: "center", gap: "4px" }}
>
<Check size={14} /> {profile.language === "de" ? "Speichern" : "Save"}
</button>
</div>
</div>
) : (
<>
<ProjectIcon icon={p.icon} size={20} color={p.color || "#999"} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, display: "block" }}>{p.name}</span>
</div>
<button
onClick={() => {
setEditingProjectId(p.id);
setEditProjectName(p.name);
setEditProjectColor(p.color || "#3b82f6");
setEditProjectIcon(p.icon || "folder");
setShowEditProjectIconPicker(false);
}}
style={{ padding: "6px", opacity: 0.6, cursor: "pointer", background: "none", border: "none", borderRadius: "6px" }}
title={profile.language === "de" ? "Bearbeiten" : "Edit"}
>
<Pencil size={14} />
</button>
<button
onClick={() => {
const msg = profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`;
if (confirm(msg)) {
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
}
}}
style={{ padding: "6px", opacity: 0.6, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }}
title={profile.language === "de" ? "Löschen" : "Delete"}
>
<Trash2 size={14} />
</button>
</>
)}
</div>
))}
</div>
)}
{/* Add new project form */}
<div style={{ borderTop: "1px solid var(--weekly-border, #e5e7eb)", paddingTop: "16px" }}>
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", marginBottom: "10px" }}>
{profile.language === "de" ? "Projekt hinzufügen" : "Add Project"}
</p>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)} style={{ width: "38px", height: "38px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-bg, #fff)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={newProjectIcon} size={16} color="#555" />
</button>
{showNewProjectIconPicker && (
<div style={{ position: "absolute", bottom: "100%", left: 0, marginBottom: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={newProjectIcon} onSelect={(name) => { setNewProjectIcon(name); setShowNewProjectIconPicker(false); }} darkMode={false} />
</div>
)}
</div>
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "30px", height: "30px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} />
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder={profile.language === "de" ? "Name" : "Name"}
className="weekly-input"
style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }}
onKeyDown={(e) => {
if (e.key === "Enter" && newProjectName.trim()) {
fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) })
.then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); });
}
}}
/>
<button
onClick={() => {
if (!newProjectName.trim()) return;
fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) })
.then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); });
}}
className="weekly-btn-primary"
style={{ padding: "8px 14px", fontSize: "0.85rem", display: "inline-flex", alignItems: "center", gap: "4px", whiteSpace: "nowrap" }}
>
<Plus size={14} /> {profile.language === "de" ? "Hinzufügen" : "Add"}
</button>
</div>
</div>
</div>
) : activeTab === "about" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: "20px" }}

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

View File

@ -78,6 +78,7 @@ export async function readCachedEvents(
calendarId: row.calendarId,
calendarTitle: row.calendarTitle,
calendarColor: row.calendarColor,
busyStatus: row.busyStatus ?? undefined,
}));
}
@ -128,6 +129,7 @@ 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,
}));
@ -183,6 +185,7 @@ 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(),
},
@ -200,6 +203,7 @@ 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(),
},
@ -214,7 +218,22 @@ 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, externalId, provider },
where: {
userId,
provider,
OR: [
{ externalId },
{ externalId: seriesMasterId },
{ recurringEventId: seriesMasterId },
// The composite IDs of all instances start with "<seriesMasterId>::"
{ externalId: { startsWith: `${seriesMasterId}::` } },
],
},
});
}

View File

@ -863,8 +863,13 @@ export const createCalendarEvent = async (
source: 'outlook',
calendarId,
calendarTitle: '',
isRecurring: !!event.recurrence,
recurringEventId: event.recurrence ? createdEvent.id : undefined,
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,
} as CalendarEvent;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
@ -1114,6 +1119,13 @@ 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(':');
@ -1342,6 +1354,16 @@ 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,4 +1,5 @@
import nodemailer from 'nodemailer';
import { getVerificationCopy, getResetCopy } from './emailTemplates';
let _transporter: nodemailer.Transporter | null = null;
@ -26,10 +27,16 @@ function getTransporter() {
export async function sendVerificationEmail(
email: string,
code: string,
token: string
token: string,
language?: string | null,
) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
// 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 html = `
<!DOCTYPE html>
@ -43,59 +50,32 @@ 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;">
Welcome! Please verify your email address to complete your registration.
</p>
<!-- Code Box -->
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.welcome}</p>
<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;">
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>
<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>
</div>
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">
Or click the button below to verify instantly:
</p>
<!-- Button -->
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">${t.orClick}</p>
<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);">
Verify Email
</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);">${t.button}</a>
</td>
</tr>
</table>
<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>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</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;">
Simple. Beautiful. Yours.
</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
</td>
</tr>
</table>
@ -108,23 +88,22 @@ 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: `${code} Verify your email for My Weekly ToDo's`,
subject: t.subject(code),
html,
});
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
} catch (err: any) {
@ -132,9 +111,10 @@ export async function sendVerificationEmail(
}
}
export async function sendPasswordResetEmail(email: string, token: string) {
export async function sendPasswordResetEmail(email: string, token: string, language?: string | null) {
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>
@ -150,35 +130,25 @@ export async function sendPasswordResetEmail(email: string, token: string) {
<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;">
We received a request to reset your password. Click the button below to choose a new one.
</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.intro}</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);">
Reset Password
</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);">${t.button}</a>
</td>
</tr>
</table>
<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>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</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;">
Simple. Beautiful. Yours.
</p>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
</td>
</tr>
</table>
@ -191,22 +161,22 @@ export async function sendPasswordResetEmail(email: string, token: string) {
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: `Password Reset My Weekly ToDo's`,
subject: t.subject,
html,
});
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`);
} catch (err: any) {

135
src/lib/emailTemplates.ts Normal file
View File

@ -0,0 +1,135 @@
// 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,6 +147,22 @@ 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,6 +87,32 @@ 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).
*/
@ -158,6 +184,78 @@ 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.
*/
@ -168,6 +266,7 @@ export const createMsTodoTask = async (
title: string;
body?: string;
dueDateTime?: string;
importance?: 'low' | 'normal' | 'high';
}
): Promise<MicrosoftTodoTask> => {
const requestBody: any = { title: taskData.title };
@ -181,6 +280,9 @@ 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`,
@ -214,6 +316,7 @@ export const updateMsTodoTask = async (
body?: string;
status?: 'notStarted' | 'completed';
dueDateTime?: string | null;
importance?: 'low' | 'normal' | 'high';
}
): Promise<MicrosoftTodoTask> => {
const body: any = {};
@ -236,6 +339,7 @@ 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,54 +201,7 @@ export const getUpcomingEvents = async (
}
const data = await response.json();
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',
})),
};
});
return data.value.map((event: any) => mapOutlookEventResponse(event));
};
const ensureTimeZone = (dateTimeObj: any) => {
@ -283,6 +236,45 @@ 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,
@ -336,15 +328,7 @@ export const createEvent = async (
}
const created = await response.json();
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
};
return mapOutlookEventResponse(created);
};
/**
@ -419,15 +403,7 @@ export const updateEvent = async (
}
const updated = await response.json();
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
};
return mapOutlookEventResponse(updated);
};
/**

97
src/lib/timezones.ts Normal file
View File

@ -0,0 +1,97 @@
// 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,7 +1,6 @@
{
"compilerOptions": {
"target": "es2017",
"downlevelIteration": true,
"lib": [
"dom",
"dom.iterable",
@ -14,7 +13,7 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",

File diff suppressed because one or more lines are too long