Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ce5664621 | |||
| 5510197cf4 | |||
| 02b2e40649 | |||
| 4a18027068 | |||
| 4f140baa38 | |||
| 3cb59583ff | |||
| e7eb060643 | |||
| 530006060a | |||
| b9ed032f1f | |||
| 0a67745b85 | |||
| 5ebdf6d751 | |||
| 07401d3dfa | |||
| 0e189e53b3 | |||
| 4a5910779e | |||
| 10e7ba98f1 | |||
| cb17c91ca6 | |||
| b1e549dbfa | |||
| cf504846da | |||
| 75bb3301f9 | |||
| 73a657ebff | |||
| 438cd71a08 | |||
| 27756e5bf5 | |||
| ab8d96a810 | |||
| 671c91c3d7 | |||
| 81f3a42620 | |||
| 94098fdac2 | |||
| 74268edb85 | |||
| b53eff9fd1 | |||
| 6325e07912 | |||
| aa322dfac3 | |||
| c3099d37c2 | |||
| 5d55beeb55 | |||
| 55ef3614d1 | |||
| 6ccf61f09a | |||
| 85912beb4b | |||
| 50aa310a2d | |||
| f0df18c853 | |||
| 16e51be26d | |||
| 1bfbe4582f | |||
| 2f29355bb9 | |||
| 9ef2b9fe5f | |||
| 9104625353 | |||
| 0e09711432 | |||
| c3dd70a438 | |||
| 754fa6ad25 | |||
| 6f610ac669 | |||
| f54dde3689 | |||
| ac19e13714 | |||
| 8958df03ce | |||
| f61f5ddb7a | |||
| bf2819ce67 | |||
| 42905641cd | |||
| 55cc066707 | |||
| 8c247e9115 | |||
| 6847e59701 | |||
| 9ce9f4484a | |||
| a040ba128b | |||
| 8c0f7acfd8 | |||
| d3a40e32a0 | |||
| ffe2f46c64 | |||
| ab70733c24 | |||
| 291ff23859 | |||
| d3fedc4d0d | |||
| 52d160ccea | |||
| 3e19ebe85a | |||
| aa4c6a085d | |||
| cf7e138bc9 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -40,4 +40,10 @@ prisma/dev.db-journal
|
|||||||
# Next.js
|
# Next.js
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
# Design reference files (not tracked)
|
||||||
|
docs/design/
|
||||||
|
|
||||||
|
# Dokumentationen (nicht tracken)
|
||||||
|
APP_ERKLÄRUNG.md
|
||||||
20
CHANGELOG.md
Normal file
20
CHANGELOG.md
Normal 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.
|
||||||
12
My-Weekly-ToDo-List.code-workspace
Normal file
12
My-Weekly-ToDo-List.code-workspace
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
clear-data.ts
Normal file
27
clear-data.ts
Normal 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
255
docs/DESIGN_NOTES.md
Normal 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), 7–8px (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: `120–180ms`, `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 | 4–10. 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 | 4–10. 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
214
fill-data-v2.ts
Normal 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
153
fill-data.ts
Normal 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
118
fill-ext-events.js
Normal 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
116
fill-ext-events.ts
Normal 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
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.92.0",
|
"version": "1.104.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.92.0",
|
"version": "1.104.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
3
prisma/migrations/20260708_add_custom_css/migration.sql
Normal file
3
prisma/migrations/20260708_add_custom_css/migration.sql
Normal 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;
|
||||||
@ -93,6 +93,8 @@ model User {
|
|||||||
yearFontWeight String? @default("700")
|
yearFontWeight String? @default("700")
|
||||||
showTaskCheckboxes Boolean @default(false)
|
showTaskCheckboxes Boolean @default(false)
|
||||||
showProjectIcons Boolean @default(false)
|
showProjectIcons Boolean @default(false)
|
||||||
|
showPriorityIcons Boolean @default(true)
|
||||||
|
priorityStyle String @default("eisenhower")
|
||||||
weekStartDay Int @default(1)
|
weekStartDay Int @default(1)
|
||||||
emailVerificationCode String?
|
emailVerificationCode String?
|
||||||
dayHeaderGap String? @default("0.75em")
|
dayHeaderGap String? @default("0.75em")
|
||||||
@ -114,6 +116,8 @@ model User {
|
|||||||
weatherLon Float?
|
weatherLon Float?
|
||||||
viewSettings Json?
|
viewSettings Json?
|
||||||
weatherRecentCities Json?
|
weatherRecentCities Json?
|
||||||
|
customCssVars Json?
|
||||||
|
customCss String? @db.Text
|
||||||
hasCompletedOnboarding Boolean @default(true)
|
hasCompletedOnboarding Boolean @default(true)
|
||||||
showCalendarProviderIcon Boolean @default(false)
|
showCalendarProviderIcon Boolean @default(false)
|
||||||
headerCurrentDayFormat String? @default("DDD, DD. MMMM YYYY")
|
headerCurrentDayFormat String? @default("DDD, DD. MMMM YYYY")
|
||||||
@ -226,11 +230,14 @@ model SomedayList {
|
|||||||
title String
|
title String
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
tab String?
|
tab String?
|
||||||
|
color String?
|
||||||
|
icon String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
externalId String?
|
externalId String?
|
||||||
externalProvider String?
|
externalProvider String?
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
|
syncDeltaToken String?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
|
||||||
@ -275,6 +282,7 @@ model CachedCalendarEvent {
|
|||||||
recurringEventId String?
|
recurringEventId String?
|
||||||
isRecurring Boolean @default(false)
|
isRecurring Boolean @default(false)
|
||||||
reminders Json?
|
reminders Json?
|
||||||
|
busyStatus String?
|
||||||
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export async function POST(request: NextRequest) {
|
|||||||
id: true,
|
id: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
emailVerificationExpires: true,
|
emailVerificationExpires: true,
|
||||||
|
language: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendVerificationEmail(email, code, token);
|
await sendVerificationEmail(email, code, token, user.language);
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Failed to resend verification email:', emailError);
|
console.error('Failed to resend verification email:', emailError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -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.' });
|
return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
||||||
|
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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) {
|
if (!email || !password) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -42,11 +46,12 @@ export async function POST(request: NextRequest) {
|
|||||||
emailVerificationCode: code,
|
emailVerificationCode: code,
|
||||||
emailVerificationToken: token,
|
emailVerificationToken: token,
|
||||||
emailVerificationExpires: expires,
|
emailVerificationExpires: expires,
|
||||||
|
language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendVerificationEmail(email, code, token);
|
await sendVerificationEmail(email, code, token, language);
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Failed to send verification email:', emailError);
|
console.error('Failed to send verification email:', emailError);
|
||||||
}
|
}
|
||||||
@ -77,6 +82,7 @@ export async function POST(request: NextRequest) {
|
|||||||
name: name || undefined,
|
name: name || undefined,
|
||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
language,
|
||||||
emailVerificationCode: code,
|
emailVerificationCode: code,
|
||||||
emailVerificationToken: token,
|
emailVerificationToken: token,
|
||||||
emailVerificationExpires: expires,
|
emailVerificationExpires: expires,
|
||||||
@ -92,7 +98,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Send verification email
|
// Send verification email
|
||||||
try {
|
try {
|
||||||
await sendVerificationEmail(email, code, token);
|
await sendVerificationEmail(email, code, token, language);
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Failed to send verification email:', emailError);
|
console.error('Failed to send verification email:', emailError);
|
||||||
// User is created but email failed — they can use "resend" later
|
// User is created but email failed — they can use "resend" later
|
||||||
|
|||||||
@ -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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
const token = request.nextUrl.searchParams.get('token');
|
||||||
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) {
|
if (!token) {
|
||||||
return NextResponse.redirect(
|
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
||||||
new URL('/auth/verify-email?error=missing_token', request.url)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
emailVerificationToken: token,
|
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) {
|
if (!user) {
|
||||||
return NextResponse.redirect(
|
return NextResponse.json({ error: 'Invalid token. It may have already been used.' }, { status: 400 });
|
||||||
new URL('/auth/verify-email?error=invalid_token', request.url)
|
}
|
||||||
|
|
||||||
|
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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
@ -114,13 +141,9 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.redirect(
|
return NextResponse.json({ message: 'Email verified successfully', email: user.email });
|
||||||
new URL('/auth/login?verified=true', request.url)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification error:', error);
|
console.error('Token verification error:', error);
|
||||||
return NextResponse.redirect(
|
return NextResponse.json({ error: 'Failed to verify email' }, { status: 500 });
|
||||||
new URL('/auth/verify-email?error=server_error', request.url)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,9 +3,7 @@ import { validateCredentials } from '@/lib/apple-calendar';
|
|||||||
import { CalendarConnection } from '@/lib/calendar-sync';
|
import { CalendarConnection } from '@/lib/calendar-sync';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,12 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { google } from 'googleapis';
|
import { google } from 'googleapis';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// Google Calendar OAuth callback endpoint
|
// Google Calendar OAuth callback endpoint
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { getUserDatabases } from '@/lib/notion-calendar';
|
import { getUserDatabases } from '@/lib/notion-calendar';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|||||||
@ -2,9 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { validateCredentials } from '@/lib/synology-calendar';
|
import { validateCredentials } from '@/lib/synology-calendar';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -127,7 +127,16 @@ export async function GET(req: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try each user-configured URL first
|
// 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) {
|
for (const url of userUrls) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||||
|
|||||||
4
src/app/api/someday-lists/external/route.ts
vendored
4
src/app/api/someday-lists/external/route.ts
vendored
@ -2,15 +2,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { createGoogleClient, createGoogleTaskList } from '@/lib/google-tasks';
|
import { createGoogleClient, createGoogleTaskList } from '@/lib/google-tasks';
|
||||||
import { createMsTodoList } from '@/lib/microsoft-todo';
|
import { createMsTodoList } from '@/lib/microsoft-todo';
|
||||||
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||||
import { createAppleReminderList } from '@/lib/apple-reminders';
|
import { createAppleReminderList } from '@/lib/apple-reminders';
|
||||||
import { createSynologyReminderList } from '@/lib/synology-tasks';
|
import { createSynologyReminderList } from '@/lib/synology-tasks';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { notifyUser } from '@/lib/sse';
|
import { notifyUser } from '@/lib/sse';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function resolveUserId(email: string): Promise<string | null> {
|
async function resolveUserId(email: string): Promise<string | null> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
@ -123,6 +121,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
// tasksOnly=true: soft-disconnect (keep list record with tab, just remove tasks + external link)
|
// tasksOnly=true: soft-disconnect (keep list record with tab, just remove tasks + external link)
|
||||||
const tasksOnly = searchParams.get('tasksOnly') === 'true';
|
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) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
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)
|
// Soft-delete tasks in this list (they can be recovered from trash)
|
||||||
await prisma.task.updateMany({
|
await prisma.task.updateMany({
|
||||||
where: { somedayListId: id },
|
where: { somedayListId: id },
|
||||||
@ -164,7 +191,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
notifyUser(userId, "list-changed", { action: "deleted" });
|
notifyUser(userId, "list-changed", { action: "deleted" });
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true, externalDeleteFailed });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting someday list:', error);
|
console.error('Error deleting someday list:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -209,8 +236,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Single Update (Title and/or Tab)
|
// Handle Single Update (Title, Tab, Color, Icon)
|
||||||
const { id, title, tab } = body;
|
const { id, title, tab, color, icon } = body;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -234,6 +261,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
const data: Record<string, any> = {};
|
const data: Record<string, any> = {};
|
||||||
if (title !== undefined) data.title = title;
|
if (title !== undefined) data.title = title;
|
||||||
if (tab !== undefined) data.tab = tab;
|
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({
|
const list = await prisma.somedayList.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@ -2,13 +2,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/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 { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
||||||
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted, fetchMsChecklistItems } from '@/lib/microsoft-todo';
|
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted, fetchMsChecklistItems } from '@/lib/microsoft-todo';
|
||||||
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
interface SourceList {
|
interface SourceList {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -23,6 +21,7 @@ interface ImportedTask {
|
|||||||
status: string;
|
status: string;
|
||||||
sourceListTitle: string;
|
sourceListTitle: string;
|
||||||
parentExternalId?: string;
|
parentExternalId?: string;
|
||||||
|
important?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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,
|
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
|
||||||
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
|
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
|
||||||
sourceListTitle: sourceList.title,
|
sourceListTitle: sourceList.title,
|
||||||
|
important: t.importance === 'high',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch checklist items as sub-tasks
|
// Fetch checklist items as sub-tasks
|
||||||
@ -295,6 +295,7 @@ export async function POST(req: NextRequest) {
|
|||||||
somedayListId: somedayList.id,
|
somedayListId: somedayList.id,
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: new Date(),
|
||||||
deletedAt: null, // clear soft-delete from a previous disconnect
|
deletedAt: null, // clear soft-delete from a previous disconnect
|
||||||
|
...(task.important !== undefined ? { importance: task.important ? true : existingTask.importance } : {}),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
externalToLocalId.set(task.externalId, existingTask.id);
|
externalToLocalId.set(task.externalId, existingTask.id);
|
||||||
@ -313,7 +314,8 @@ export async function POST(req: NextRequest) {
|
|||||||
externalId: task.externalId,
|
externalId: task.externalId,
|
||||||
externalProvider: provider,
|
externalProvider: provider,
|
||||||
externalListId: task.externalListId,
|
externalListId: task.externalListId,
|
||||||
lastSyncedAt: new Date()
|
lastSyncedAt: new Date(),
|
||||||
|
...(task.important ? { importance: true } : {}),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
externalToLocalId.set(task.externalId, newTask.id);
|
externalToLocalId.set(task.externalId, newTask.id);
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
||||||
import { fetchMsTodoLists } from '@/lib/microsoft-todo';
|
import { fetchMsTodoLists } from '@/lib/microsoft-todo';
|
||||||
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/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';
|
import { notifyUser } from '@/lib/sse';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// Validate and sanitize a URL — only allow http/https, reject javascript: and data: schemes
|
// Validate and sanitize a URL — only allow http/https, reject javascript: and data: schemes
|
||||||
function sanitizeUrl(raw: string | null | undefined): string | null {
|
function sanitizeUrl(raw: string | null | undefined): string | null {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@ -25,6 +24,71 @@ const generateVirtualId = (originalId: string, dateStr: string) => {
|
|||||||
return `virtual-${originalId}-${dateStr}`;
|
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.
|
// 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).
|
// Caps pathological cases (e.g. a daily task with 90-day horizon = 90 instances).
|
||||||
const MAX_INSTANCES_PER_SERIES: Record<string, number> = {
|
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 { getOutlookAccessToken } = await import('@/lib/outlook-token');
|
||||||
const accessToken = await getOutlookAccessToken(userId);
|
const accessToken = await getOutlookAccessToken(userId);
|
||||||
if (accessToken) {
|
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;
|
externalId = msTask.id;
|
||||||
externalProvider = 'outlook';
|
externalProvider = 'outlook';
|
||||||
externalListId = somedayList.externalId;
|
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.
|
// NOTE: We REMOVED the "create next task on completion" logic block here.
|
||||||
// Why? Because the projection system handles "next tasks" automatically.
|
// 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,
|
// If we kept it, completing a task would create a duplicate materialized task for the next date,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { createGoogleClient, createGoogleTask, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks';
|
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';
|
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||||
|
|
||||||
// GET - Pull changes from Google Tasks into local DB
|
// GET - Pull changes from Google Tasks into local DB
|
||||||
@ -144,11 +144,8 @@ export async function GET(req: NextRequest) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteUpdated = new Date(remote.updated);
|
// Field-by-field reconciliation (see Outlook block for rationale).
|
||||||
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
|
const updateData: any = {};
|
||||||
if (remoteUpdated <= localUpdated) continue;
|
|
||||||
|
|
||||||
const updateData: any = { lastSyncedAt: new Date() };
|
|
||||||
|
|
||||||
const remoteCompleted = remote.status === 'completed';
|
const remoteCompleted = remote.status === 'completed';
|
||||||
if (remoteCompleted !== localTask.completed) {
|
if (remoteCompleted !== localTask.completed) {
|
||||||
@ -173,17 +170,13 @@ export async function GET(req: NextRequest) {
|
|||||||
updateData.parentTaskId = null;
|
updateData.parentTaskId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 1) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
updateData.lastSyncedAt = new Date();
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: localTask.id },
|
where: { id: localTask.id },
|
||||||
data: updateData
|
data: updateData
|
||||||
});
|
});
|
||||||
updated++;
|
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);
|
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) {
|
for (const listId of outlookListIds) {
|
||||||
const localTasks = outlookByList.get(listId) || [];
|
const localTasks = outlookByList.get(listId) || [];
|
||||||
try {
|
try {
|
||||||
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
|
const syncedListRow = syncedListIdToRow.get(listId);
|
||||||
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
|
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));
|
const existingExternalIds = new Set(localTasks.map(t => t.externalId));
|
||||||
|
|
||||||
for (const localTask of localTasks) {
|
// -- Delta deletions: remote tasks marked _deleted are gone in Outlook --
|
||||||
const remote = remoteMap.get(localTask.externalId!);
|
if (isDelta) {
|
||||||
|
const deletedIds = remoteTasks.filter((r: any) => r._deleted).map((r: any) => r.id);
|
||||||
if (!remote) {
|
if (deletedIds.length > 0) {
|
||||||
await prisma.task.update({
|
const result = await prisma.task.updateMany({
|
||||||
where: { id: localTask.id },
|
where: {
|
||||||
data: { deletedAt: new Date() }
|
userId: user.id,
|
||||||
|
externalProvider: 'outlook',
|
||||||
|
externalListId: listId,
|
||||||
|
externalId: { in: deletedIds },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
});
|
});
|
||||||
deleted++;
|
deleted += result.count;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const remoteUpdated = new Date(remote.lastModifiedDateTime);
|
// For full fetches, detect locally-known tasks that vanished from Outlook
|
||||||
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
|
if (!isDelta) {
|
||||||
if (remoteUpdated <= localUpdated) continue;
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateData: any = { lastSyncedAt: new Date() };
|
// -- 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;
|
||||||
|
|
||||||
|
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);
|
const remoteCompleted = isMsTodoTaskCompleted(remote.status);
|
||||||
if (remoteCompleted !== localTask.completed) {
|
if (remoteCompleted !== localTask.completed) {
|
||||||
@ -315,24 +387,36 @@ export async function GET(req: NextRequest) {
|
|||||||
updateData.description = remoteNotes;
|
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({
|
await prisma.task.update({
|
||||||
where: { id: localTask.id },
|
where: { id: localTask.id },
|
||||||
data: updateData
|
data: updateData
|
||||||
});
|
});
|
||||||
updated++;
|
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
|
// -- Create new local tasks for remote tasks we don't have yet --
|
||||||
const somedayListInfo = outlookListIdToSomedayList.get(listId);
|
if (syncedListRow) {
|
||||||
if (somedayListInfo) {
|
const newRemoteTasks = reconcileTargets.filter((rt: any) => !existingExternalIds.has(rt.id));
|
||||||
const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id));
|
|
||||||
|
|
||||||
for (const remote of newRemoteTasks) {
|
for (const remote of newRemoteTasks) {
|
||||||
if (!remote.title || !remote.title.trim()) continue;
|
if (!remote.title || !remote.title.trim()) continue;
|
||||||
@ -343,7 +427,11 @@ export async function GET(req: NextRequest) {
|
|||||||
title: remote.title,
|
title: remote.title,
|
||||||
description: remote.body?.content || null,
|
description: remote.body?.content || null,
|
||||||
completed: isMsTodoTaskCompleted(remote.status),
|
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,
|
externalId: remote.id,
|
||||||
externalProvider: 'outlook',
|
externalProvider: 'outlook',
|
||||||
externalListId: listId,
|
externalListId: listId,
|
||||||
@ -354,6 +442,14 @@ export async function GET(req: NextRequest) {
|
|||||||
created++;
|
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) {
|
} catch (listError) {
|
||||||
console.error(`Error syncing Outlook list ${listId}:`, 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 body = await req.json();
|
||||||
const { taskId, completed, title, action, scheduledDate, notes } = body;
|
const { taskId, completed, title, action, scheduledDate, notes, importance } = body;
|
||||||
|
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
return NextResponse.json({ error: 'Task ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'Task ID required' }, { status: 400 });
|
||||||
@ -538,13 +634,16 @@ export async function PATCH(req: NextRequest) {
|
|||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId);
|
await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId);
|
||||||
} else {
|
} 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 (title !== undefined) updates.title = title;
|
||||||
if (notes !== undefined) updates.body = notes;
|
if (notes !== undefined) updates.body = notes;
|
||||||
if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted';
|
if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted';
|
||||||
if (scheduledDate !== undefined) {
|
if (scheduledDate !== undefined) {
|
||||||
updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null;
|
updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null;
|
||||||
}
|
}
|
||||||
|
if (importance !== undefined) {
|
||||||
|
updates.importance = importance ? 'high' : 'normal';
|
||||||
|
}
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates);
|
await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates);
|
||||||
}
|
}
|
||||||
@ -658,6 +757,7 @@ export async function POST(req: NextRequest) {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
body: task.description || undefined,
|
body: task.description || undefined,
|
||||||
dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
|
dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
|
||||||
|
importance: task.importance ? 'high' : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedTask = await prisma.task.update({
|
const updatedTask = await prisma.task.update({
|
||||||
|
|||||||
@ -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 {
|
function fmtHour(h: number): string {
|
||||||
return `${String(h).padStart(2, '0')}:00`;
|
return `${String(h).padStart(2, '0')}:00`;
|
||||||
}
|
}
|
||||||
@ -454,7 +470,7 @@ function WeekCalendarPDF({
|
|||||||
return React.createElement(View, {
|
return React.createElement(View, {
|
||||||
key: t.id,
|
key: t.id,
|
||||||
style: { borderRadius: 2, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, backgroundColor: DONE_BG },
|
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) {
|
if (t.isExternal) {
|
||||||
// Calendar event: solid color chip with white text + dot indicator (matches webapp style)
|
// Calendar event: solid color chip with white text + dot indicator (matches webapp style)
|
||||||
@ -473,7 +489,7 @@ function WeekCalendarPDF({
|
|||||||
React.createElement(View, {
|
React.createElement(View, {
|
||||||
style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: evTextColor, opacity: 0.7, marginRight: 3, flexShrink: 0 },
|
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
|
// User-created all-day task: left border only, no background fill
|
||||||
@ -481,7 +497,7 @@ function WeekCalendarPDF({
|
|||||||
return React.createElement(View, {
|
return React.createElement(View, {
|
||||||
key: t.id,
|
key: t.id,
|
||||||
style: { borderLeftWidth: 2, borderLeftColor: accent, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2 },
|
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, {
|
allDayTasks.length > 5 ? React.createElement(Text, {
|
||||||
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
|
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
|
||||||
@ -575,7 +591,7 @@ function WeekCalendarPDF({
|
|||||||
key: t.id,
|
key: t.id,
|
||||||
style: { marginBottom: 1, paddingLeft: 1, paddingVertical: 1 },
|
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,
|
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,
|
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, {
|
React.createElement(Text, {
|
||||||
style: { fontSize: 7, color: '#374151', lineHeight: 1.3, flex: 1 },
|
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, {
|
list.tasks.length > 18 ? React.createElement(Text, {
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export async function GET(request: NextRequest) {
|
|||||||
showSchedule: true,
|
showSchedule: true,
|
||||||
showTaskCheckboxes: true,
|
showTaskCheckboxes: true,
|
||||||
showProjectIcons: true,
|
showProjectIcons: true,
|
||||||
|
showPriorityIcons: true,
|
||||||
|
priorityStyle: true,
|
||||||
cellDuration: true,
|
cellDuration: true,
|
||||||
viewStyle: true,
|
viewStyle: true,
|
||||||
viewDays: true,
|
viewDays: true,
|
||||||
@ -109,6 +111,8 @@ export async function GET(request: NextRequest) {
|
|||||||
weatherLocation: true,
|
weatherLocation: true,
|
||||||
weatherRecentCities: true,
|
weatherRecentCities: true,
|
||||||
viewSettings: true,
|
viewSettings: true,
|
||||||
|
customCssVars: true,
|
||||||
|
customCss: true,
|
||||||
hasCompletedOnboarding: true,
|
hasCompletedOnboarding: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
}
|
}
|
||||||
@ -150,10 +154,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
hourLabelFormat, showSubHourSlots, allDayPosition,
|
hourLabelFormat, showSubHourSlots, allDayPosition,
|
||||||
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
|
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
|
||||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||||
showTaskCheckboxes, showProjectIcons, dayHeaderGap,
|
showTaskCheckboxes, showProjectIcons, showPriorityIcons, priorityStyle, dayHeaderGap,
|
||||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages,
|
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages,
|
||||||
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
|
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
|
||||||
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
|
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
|
||||||
|
customCssVars, customCss,
|
||||||
hasCompletedOnboarding
|
hasCompletedOnboarding
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
@ -178,6 +183,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(showSchedule !== undefined && { showSchedule }),
|
...(showSchedule !== undefined && { showSchedule }),
|
||||||
...(showTaskCheckboxes !== undefined && { showTaskCheckboxes }),
|
...(showTaskCheckboxes !== undefined && { showTaskCheckboxes }),
|
||||||
...(showProjectIcons !== undefined && { showProjectIcons }),
|
...(showProjectIcons !== undefined && { showProjectIcons }),
|
||||||
|
...(showPriorityIcons !== undefined && { showPriorityIcons }),
|
||||||
|
...(priorityStyle !== undefined && { priorityStyle }),
|
||||||
...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }),
|
...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }),
|
||||||
...(viewStyle !== undefined && { viewStyle }),
|
...(viewStyle !== undefined && { viewStyle }),
|
||||||
...(viewDays !== undefined && !isNaN(viewDays) && { viewDays }),
|
...(viewDays !== undefined && !isNaN(viewDays) && { viewDays }),
|
||||||
@ -252,6 +259,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(weatherLocation !== undefined && { weatherLocation }),
|
...(weatherLocation !== undefined && { weatherLocation }),
|
||||||
...(weatherRecentCities !== undefined && { weatherRecentCities }),
|
...(weatherRecentCities !== undefined && { weatherRecentCities }),
|
||||||
...(viewSettings !== undefined && { viewSettings }),
|
...(viewSettings !== undefined && { viewSettings }),
|
||||||
|
...(customCssVars !== undefined && { customCssVars }),
|
||||||
|
...(customCss !== undefined && { customCss }),
|
||||||
...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }),
|
...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }),
|
||||||
};
|
};
|
||||||
if (password && password.trim() !== "") {
|
if (password && password.trim() !== "") {
|
||||||
@ -283,6 +292,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
showSchedule: true,
|
showSchedule: true,
|
||||||
showTaskCheckboxes: true,
|
showTaskCheckboxes: true,
|
||||||
showProjectIcons: true,
|
showProjectIcons: true,
|
||||||
|
showPriorityIcons: true,
|
||||||
|
priorityStyle: true,
|
||||||
cellDuration: true,
|
cellDuration: true,
|
||||||
viewStyle: true,
|
viewStyle: true,
|
||||||
viewDays: true,
|
viewDays: true,
|
||||||
@ -358,6 +369,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
weatherLocation: true,
|
weatherLocation: true,
|
||||||
weatherRecentCities: true,
|
weatherRecentCities: true,
|
||||||
viewSettings: true,
|
viewSettings: true,
|
||||||
|
customCssVars: true,
|
||||||
|
customCss: true,
|
||||||
hasCompletedOnboarding: true,
|
hasCompletedOnboarding: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,10 +34,11 @@ export default function SignupPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const language = (typeof navigator !== 'undefined' ? navigator.language : 'en').slice(0, 2).toLowerCase();
|
||||||
const response = await fetch('/api/auth/signup', {
|
const response = await fetch('/api/auth/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, email, password }),
|
body: JSON.stringify({ name, email, password, language }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@ -7,13 +7,16 @@ import Link from 'next/link';
|
|||||||
function VerifyEmailContent() {
|
function VerifyEmailContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const email = searchParams.get('email') || '';
|
const initialEmail = searchParams.get('email') || '';
|
||||||
|
const tokenFromUrl = searchParams.get('token') || '';
|
||||||
const error = searchParams.get('error');
|
const error = searchParams.get('error');
|
||||||
|
const [email, setEmail] = useState(initialEmail);
|
||||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
const [isResending, setIsResending] = useState(false);
|
const [isResending, setIsResending] = useState(false);
|
||||||
|
const [isTokenVerifying, setIsTokenVerifying] = useState(false);
|
||||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -26,10 +29,38 @@ function VerifyEmailContent() {
|
|||||||
}
|
}
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
// Auto-focus first input
|
// Auto-focus first input (only if we're not in token-link mode)
|
||||||
useEffect(() => {
|
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) => {
|
const handleInput = (index: number, value: string) => {
|
||||||
// Handle paste of full code
|
// 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' }}>
|
<h2 style={{ color: '#333', fontSize: '1.25rem', fontWeight: 600, textAlign: 'center', margin: '0 0 8px' }}>
|
||||||
Verify your email
|
Verify your email
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
{tokenFromUrl ? (
|
||||||
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email}</strong>
|
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
||||||
</p>
|
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 */}
|
{/* Messages */}
|
||||||
{errorMsg && (
|
{errorMsg && (
|
||||||
@ -165,6 +202,38 @@ function VerifyEmailContent() {
|
|||||||
</div>
|
</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 */}
|
{/* Code Input */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@ -32,6 +32,7 @@ body {
|
|||||||
background-color: #f8fafc;
|
background-color: #f8fafc;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,6 +475,46 @@ h3 {
|
|||||||
--weekly-settings-toggle-active-text: #111827;
|
--weekly-settings-toggle-active-text: #111827;
|
||||||
--weekly-header-brightness: 1;
|
--weekly-header-brightness: 1;
|
||||||
--weekly-goal-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 */
|
/* Weekly Main Container */
|
||||||
@ -492,7 +533,7 @@ h3 {
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: var(--weekly-bg);
|
background: var(--weekly-bg);
|
||||||
border-bottom: 1px solid var(--weekly-border);
|
border-bottom: 1px solid var(--line);
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1237,7 +1278,7 @@ h3 {
|
|||||||
.weekly-someday {
|
.weekly-someday {
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 1rem;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@ -1319,14 +1360,15 @@ h3 {
|
|||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
gap: 1.5rem;
|
gap: 12px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
padding-bottom: 1rem;
|
padding: 12px 12px 16px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
overflow: visible; /* Let parent container handle scrolling */
|
overflow: visible;
|
||||||
|
background-color: #F9F9F9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar Styling for Someday Container */
|
/* Scrollbar Styling for Someday Container */
|
||||||
@ -1372,37 +1414,45 @@ h3 {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ruled paper lines for someday tasks */
|
/* Someday list cards */
|
||||||
.weekly-someday-list {
|
.weekly-someday-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
flex: 0 0 300px; /* Slightly wider lists */
|
flex: 0 0 300px;
|
||||||
width: 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%;
|
max-width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
scrollbar-width: none; /* Hide scrollbar Firefox */
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none; /* Hide scrollbar IE/Edge */
|
-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 {
|
.weekly-someday-list::-webkit-scrollbar {
|
||||||
display: none; /* Hide scrollbar Chrome/Safari/Webkit */
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekly-container.dark-mode .weekly-someday-list {
|
.weekly-container.dark-mode .weekly-someday-list {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-list-slot {
|
.task-list-slot {
|
||||||
height: 38px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--weekly-border);
|
border-bottom: 1px solid var(--weekly-border);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-list-slot.drop-target {
|
.task-list-slot.drop-target {
|
||||||
@ -1463,7 +1513,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weekly-someday-list:last-child {
|
.weekly-someday-list:last-child {
|
||||||
border-right: none;
|
/* gap handles spacing, no extra right border needed */
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekly-someday-list.is-dragging {
|
.weekly-someday-list.is-dragging {
|
||||||
@ -1473,9 +1523,9 @@ h3 {
|
|||||||
|
|
||||||
.weekly-someday-list .weekly-task-item {
|
.weekly-someday-list .weekly-task-item {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
min-height: 38px;
|
min-height: 24px;
|
||||||
height: auto;
|
height: auto;
|
||||||
padding: 6px 1rem;
|
padding: 4px 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
@ -1495,36 +1545,38 @@ h3 {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-overflow: clip;
|
text-overflow: clip;
|
||||||
padding: 0;
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekly-someday-list-title-header {
|
.weekly-someday-list-title-header {
|
||||||
padding: 0 1rem;
|
padding: 10px 12px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
height: 56px;
|
min-height: 48px;
|
||||||
min-height: 56px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border-bottom: 1px solid var(--weekly-border);
|
border-bottom: 1px solid var(--weekly-border);
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekly-container.dark-mode .weekly-someday-list-title-header {
|
.weekly-container.dark-mode .weekly-someday-list-title-header {
|
||||||
border-bottom-color: var(--weekly-border);
|
border-bottom-color: var(--weekly-border);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekly-someday-list-title-input {
|
.weekly-someday-list-title-input {
|
||||||
font-size: 1.15rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--weekly-text, #222);
|
color: var(--weekly-text, #222);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 4px 0;
|
padding: 2px 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekly-someday .weekly-task-item {
|
.weekly-someday .weekly-task-item {
|
||||||
@ -1542,29 +1594,29 @@ h3 {
|
|||||||
.someday-add-task-btn {
|
.someday-add-task-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #aaa;
|
color: #bbb;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
padding: 4px 0;
|
padding: 8px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.someday-add-task-btn:hover {
|
.someday-add-task-btn:hover {
|
||||||
color: #666;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Someday Tabs Bar (horizontal above grid) */
|
/* Someday Tabs Bar (horizontal above grid) */
|
||||||
.someday-tabs-bar {
|
.someday-tabs-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px 8px;
|
padding: 0px 12px 0;
|
||||||
border-bottom: 1px solid var(--weekly-border);
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
background-color: #EEEEEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
.someday-tabs-bar::-webkit-scrollbar {
|
.someday-tabs-bar::-webkit-scrollbar {
|
||||||
@ -1576,13 +1628,14 @@ h3 {
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--weekly-text-light, #999);
|
color: var(--weekly-text-light, #999);
|
||||||
padding: 3px;
|
padding: 5px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding:8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.someday-bar-icon-btn:hover {
|
.someday-bar-icon-btn:hover {
|
||||||
@ -1592,60 +1645,69 @@ h3 {
|
|||||||
|
|
||||||
.someday-tabs-bar-divider {
|
.someday-tabs-bar-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 16px;
|
height: 34px;
|
||||||
background: var(--weekly-border, #e5e7eb);
|
background: var(--weekly-border, #e5e7eb);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0 2px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Someday Tab Buttons (horizontal) */
|
|
||||||
|
/* Someday Tab Buttons (horizontal) — pill + underline style */
|
||||||
.someday-tab-btn-h {
|
.someday-tab-btn-h {
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--weekly-text-light, #999);
|
color: var(--weekly-text-light, #888);
|
||||||
font-size: 0.75rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 3px 10px;
|
padding: 5px 12px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 6px 6px 0 0;
|
||||||
white-space: nowrap;
|
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 {
|
.someday-tab-btn-h:hover {
|
||||||
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
|
|
||||||
color: var(--weekly-text, #333);
|
color: var(--weekly-text, #333);
|
||||||
|
border-color: var(--weekly-border, #e5e7eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.someday-tab-btn-h.active {
|
.someday-tab-btn-h.active {
|
||||||
background: var(--weekly-accent, #6366f1);
|
background: #f9f9f9;
|
||||||
color: #fff;
|
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 {
|
.someday-tab-btn-h.drag-over {
|
||||||
outline: 2px dashed var(--weekly-accent, #6366f1);
|
outline: 2px dashed var(--weekly-accent, #6366f1);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.someday-tab-count {
|
.someday-tab-count {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 16px;
|
min-width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
padding: 0 4px;
|
padding: 0 5px;
|
||||||
border-radius: 8px;
|
border-radius: 9px;
|
||||||
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.08));
|
background: rgba(0, 0, 0, 0.07);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-left: 2px;
|
color: var(--weekly-text-light, #888);
|
||||||
}
|
}
|
||||||
|
|
||||||
.someday-tab-btn-h.active .someday-tab-count {
|
.someday-tab-btn-h.active .someday-tab-count {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
color: #fff;
|
color: var(--weekly-accent, #000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Someday Tab Wrapper with dissolve button */
|
/* Someday Tab Wrapper with dissolve button */
|
||||||
@ -1658,7 +1720,7 @@ h3 {
|
|||||||
.someday-tab-dissolve-h {
|
.someday-tab-dissolve-h {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
top: -4px;
|
top: -6px;
|
||||||
background: var(--weekly-bg, #fff);
|
background: var(--weekly-bg, #fff);
|
||||||
border: 1px solid var(--weekly-border, #e5e7eb);
|
border: 1px solid var(--weekly-border, #e5e7eb);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@ -2893,7 +2955,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time-slot-label span {
|
.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);
|
background: var(--weekly-bg);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@ -3208,26 +3270,29 @@ h3 {
|
|||||||
|
|
||||||
/* Resize handle for draggable section borders */
|
/* Resize handle for draggable section borders */
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
height: 7px;
|
height: 2px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 50;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-top: 1px solid var(--weekly-border, #e5e7eb);
|
overflow: visible;
|
||||||
border-bottom: 1px solid var(--weekly-border, #e5e7eb);
|
background-color: #ddd;
|
||||||
}
|
}
|
||||||
.resize-handle:hover {
|
.resize-handle:hover {
|
||||||
background: rgba(59, 130, 246, 0.08);
|
background-color: #d7d7d7;
|
||||||
border-color: var(--weekly-accent, #3b82f6);
|
border-color: #b8b8b8;
|
||||||
}
|
}
|
||||||
.resize-handle:active {
|
.resize-handle:active {
|
||||||
background: rgba(59, 130, 246, 0.12);
|
background-color: #d1d1d1;
|
||||||
border-color: var(--weekly-accent, #3b82f6);
|
border-color: #aaa;
|
||||||
}
|
}
|
||||||
.resize-handle-bar {
|
.resize-handle-bar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
@ -3237,10 +3302,50 @@ h3 {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: background 0.15s, opacity 0.15s;
|
transition: background 0.15s, opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
.resize-handle-anyday {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
.resize-handle-allday {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
.resize-handle-anyday .resize-handle-bar {
|
||||||
|
width: 23px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: radial-gradient(circle, color-mix(in srgb, var(--weekly-text, #333) 48%, transparent) 1.25px, transparent 1.35px) 2px 1px / 6px 5px repeat;
|
||||||
|
background-color: #ddd;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
opacity: 1;
|
||||||
|
position: relative;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.resize-handle-allday .resize-handle-bar {
|
||||||
|
width: 23px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: radial-gradient(circle, color-mix(in srgb, var(--weekly-text, #333) 48%, transparent) 1.25px, transparent 1.35px) 2px 1px / 6px 5px repeat;
|
||||||
|
background-color: #ddd;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
opacity: 1;
|
||||||
|
position: relative;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
.resize-handle:hover .resize-handle-bar,
|
.resize-handle:hover .resize-handle-bar,
|
||||||
.resize-handle:active .resize-handle-bar {
|
.resize-handle:active .resize-handle-bar {
|
||||||
background: var(--weekly-accent, #3b82f6);
|
opacity: 1;
|
||||||
opacity: 0.5;
|
}
|
||||||
|
.resize-handle-anyday:hover .resize-handle-bar,
|
||||||
|
.resize-handle-anyday:active .resize-handle-bar,
|
||||||
|
.resize-handle-allday:hover .resize-handle-bar,
|
||||||
|
.resize-handle-allday:active .resize-handle-bar {
|
||||||
|
background: radial-gradient(circle, var(--weekly-accent, #3b82f6) 1.25px, transparent 1.35px) 2px 1.5px / 6px 5px repeat;
|
||||||
|
background-color: #d7d7d7;
|
||||||
|
border-color: #b8b8b8;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.all-day-events-header {
|
.all-day-events-header {
|
||||||
@ -4729,6 +4834,42 @@ h3 {
|
|||||||
opacity: 0.7;
|
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 Actions in Sidebar --- */
|
||||||
.mobile-quick-nav-btn {
|
.mobile-quick-nav-btn {
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
|
import React, { useState, useEffect, lazy, Suspense, useRef, useCallback } from 'react';
|
||||||
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity, Check } from 'lucide-react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
||||||
@ -27,7 +27,7 @@ function AnimatedDots() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reminder preset options (minutes)
|
// Reminder preset options (minutes)
|
||||||
const REMINDER_OPTIONS = [
|
const REMINDER_PRESETS = [
|
||||||
{ label: 'None', value: -1 },
|
{ label: 'None', value: -1 },
|
||||||
{ label: 'At time of event', value: 0 },
|
{ label: 'At time of event', value: 0 },
|
||||||
{ label: '5 minutes before', value: 5 },
|
{ label: '5 minutes before', value: 5 },
|
||||||
@ -40,6 +40,25 @@ const REMINDER_OPTIONS = [
|
|||||||
{ label: '1 week before', value: 10080 },
|
{ label: '1 week before', value: 10080 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CUSTOM_REMINDER_SENTINEL = -9999;
|
||||||
|
|
||||||
|
function formatReminderMinutes(minutes: number, language: string): string {
|
||||||
|
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
|
||||||
|
if (minutes % 10080 === 0) {
|
||||||
|
const w = minutes / 10080;
|
||||||
|
return language === 'de' ? `${w} ${w === 1 ? 'Woche' : 'Wochen'} vorher` : `${w} week${w !== 1 ? 's' : ''} before`;
|
||||||
|
}
|
||||||
|
if (minutes % 1440 === 0) {
|
||||||
|
const d = minutes / 1440;
|
||||||
|
return language === 'de' ? `${d} ${d === 1 ? 'Tag' : 'Tage'} vorher` : `${d} day${d !== 1 ? 's' : ''} before`;
|
||||||
|
}
|
||||||
|
if (minutes % 60 === 0) {
|
||||||
|
const h = minutes / 60;
|
||||||
|
return language === 'de' ? `${h} ${h === 1 ? 'Stunde' : 'Stunden'} vorher` : `${h} hour${h !== 1 ? 's' : ''} before`;
|
||||||
|
}
|
||||||
|
return language === 'de' ? `${minutes} Minuten vorher` : `${minutes} minutes before`;
|
||||||
|
}
|
||||||
|
|
||||||
const BUSY_STATUS_OPTIONS = [
|
const BUSY_STATUS_OPTIONS = [
|
||||||
{ label: 'Busy', value: 'busy' },
|
{ label: 'Busy', value: 'busy' },
|
||||||
{ label: 'Free', value: 'free' },
|
{ label: 'Free', value: 'free' },
|
||||||
@ -63,6 +82,10 @@ interface CalendarEventModalProps {
|
|||||||
connections: any[];
|
connections: any[];
|
||||||
weekStartDay?: number; // 0=Sunday, 1=Monday
|
weekStartDay?: number; // 0=Sunday, 1=Monday
|
||||||
language?: string;
|
language?: string;
|
||||||
|
savedLocations?: string[];
|
||||||
|
onSaveLocation?: (loc: string) => void;
|
||||||
|
customReminderMinutes?: number[];
|
||||||
|
onSaveCustomReminder?: (minutes: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (eventData: any) => Promise<void>;
|
onSave: (eventData: any) => Promise<void>;
|
||||||
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
|
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
|
||||||
@ -76,6 +99,10 @@ export default function CalendarEventModal({
|
|||||||
connections,
|
connections,
|
||||||
weekStartDay = 0,
|
weekStartDay = 0,
|
||||||
language = 'en',
|
language = 'en',
|
||||||
|
savedLocations = [],
|
||||||
|
onSaveLocation,
|
||||||
|
customReminderMinutes = [],
|
||||||
|
onSaveCustomReminder,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete
|
onDelete
|
||||||
@ -167,6 +194,53 @@ export default function CalendarEventModal({
|
|||||||
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Location autocomplete
|
||||||
|
const [locationFocused, setLocationFocused] = useState(false);
|
||||||
|
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]);
|
||||||
|
const locationRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updateLocationSuggestions = useCallback((val: string) => {
|
||||||
|
if (!val.trim() || savedLocations.length === 0) {
|
||||||
|
setLocationSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lower = val.toLowerCase();
|
||||||
|
const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val);
|
||||||
|
setLocationSuggestions(matches.slice(0, 6));
|
||||||
|
}, [savedLocations]);
|
||||||
|
|
||||||
|
// Custom reminder state
|
||||||
|
// customReminderIdx tracks which reminder row is showing the custom editor
|
||||||
|
const [customEditorIdx, setCustomEditorIdx] = useState<number | null>(null);
|
||||||
|
const [customAmount, setCustomAmount] = useState(30);
|
||||||
|
const [reminderUnit, setReminderUnit] = useState<'minutes' | 'hours' | 'days'>('minutes');
|
||||||
|
|
||||||
|
const customAmountToMinutes = () => {
|
||||||
|
if (reminderUnit === 'hours') return customAmount * 60;
|
||||||
|
if (reminderUnit === 'days') return customAmount * 1440;
|
||||||
|
return customAmount;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build full reminder options list: presets + saved custom + "Custom..."
|
||||||
|
const buildReminderOptions = () => [
|
||||||
|
...REMINDER_PRESETS,
|
||||||
|
...customReminderMinutes
|
||||||
|
.filter(m => !REMINDER_PRESETS.some(p => p.value === m))
|
||||||
|
.map(m => ({ label: formatReminderMinutes(m, language), value: m })),
|
||||||
|
{ label: language === 'de' ? 'Benutzerdefiniert…' : 'Custom…', value: CUSTOM_REMINDER_SENTINEL },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Close location suggestions on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (locationRef.current && !locationRef.current.contains(e.target as Node)) {
|
||||||
|
setLocationSuggestions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Focus trap
|
// Focus trap
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@ -234,7 +308,7 @@ export default function CalendarEventModal({
|
|||||||
end: { dateTime: endDate.toISOString() },
|
end: { dateTime: endDate.toISOString() },
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
reminders: activeReminders.length > 0 ? activeReminders : undefined,
|
reminders: activeReminders.length > 0 ? activeReminders : undefined,
|
||||||
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
|
busyStatus: busyStatus,
|
||||||
visibility: visibility !== 'default' ? visibility : undefined,
|
visibility: visibility !== 'default' ? visibility : undefined,
|
||||||
attendees: attendees.length > 0 ? attendees : undefined,
|
attendees: attendees.length > 0 ? attendees : undefined,
|
||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
@ -261,6 +335,11 @@ export default function CalendarEventModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save location to user profile if non-empty and new
|
||||||
|
if (location.trim() && onSaveLocation) {
|
||||||
|
onSaveLocation(location.trim());
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
@ -348,11 +427,29 @@ export default function CalendarEventModal({
|
|||||||
const updateReminder = (index: number, minutes: number) => {
|
const updateReminder = (index: number, minutes: number) => {
|
||||||
if (minutes === -1) {
|
if (minutes === -1) {
|
||||||
setReminders(reminders.filter((_, i) => i !== index));
|
setReminders(reminders.filter((_, i) => i !== index));
|
||||||
|
setCustomEditorIdx(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (minutes === CUSTOM_REMINDER_SENTINEL) {
|
||||||
|
setCustomEditorIdx(index);
|
||||||
|
setCustomAmount(30);
|
||||||
|
setReminderUnit('minutes');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = [...reminders];
|
const updated = [...reminders];
|
||||||
updated[index] = { ...updated[index], minutes };
|
updated[index] = { ...updated[index], minutes };
|
||||||
setReminders(updated);
|
setReminders(updated);
|
||||||
|
setCustomEditorIdx(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCustomReminder = (index: number) => {
|
||||||
|
const mins = customAmountToMinutes();
|
||||||
|
if (mins <= 0) return;
|
||||||
|
const updated = [...reminders];
|
||||||
|
updated[index] = { ...updated[index], minutes: mins };
|
||||||
|
setReminders(updated);
|
||||||
|
setCustomEditorIdx(null);
|
||||||
|
if (onSaveCustomReminder) onSaveCustomReminder(mins);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addReminder = () => {
|
const addReminder = () => {
|
||||||
@ -687,24 +784,61 @@ export default function CalendarEventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location with autocomplete */}
|
||||||
<div style={iconRow}>
|
<div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
|
||||||
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
|
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={location}
|
value={location}
|
||||||
onChange={e => setLocation(e.target.value)}
|
onChange={e => { setLocation(e.target.value); updateLocationSuggestions(e.target.value); }}
|
||||||
|
onFocus={() => { setLocationFocused(true); updateLocationSuggestions(location); }}
|
||||||
|
onBlur={() => setTimeout(() => setLocationSuggestions([]), 150)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Escape') setLocationSuggestions([]); }}
|
||||||
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
|
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
|
||||||
aria-label={language === 'de' ? 'Ort' : 'Location'}
|
aria-label={language === 'de' ? 'Ort' : 'Location'}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={locationSuggestions.length > 0}
|
||||||
style={{
|
style={{
|
||||||
...fieldCol, padding: '2px 0', border: 'none',
|
...fieldCol, padding: '2px 0', border: 'none',
|
||||||
background: 'transparent', outline: 'none', fontSize: '0.8rem',
|
background: 'transparent', outline: 'none', fontSize: '0.8rem',
|
||||||
color: 'var(--weekly-text)',
|
color: 'var(--weekly-text)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{locationSuggestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label={language === 'de' ? 'Ortsvorschläge' : 'Location suggestions'}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '100%', left: '26px', right: 0,
|
||||||
|
zIndex: 200, background: 'var(--weekly-bg-popover, #fff)',
|
||||||
|
border: '1px solid var(--weekly-border, #e5e7eb)',
|
||||||
|
borderRadius: '8px', boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||||
|
overflow: 'hidden', marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationSuggestions.map((suggestion, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
role="option"
|
||||||
|
aria-selected={false}
|
||||||
|
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px', fontSize: '0.78rem',
|
||||||
|
cursor: 'pointer', color: 'var(--weekly-text)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--weekly-hover, rgba(0,0,0,0.05))')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert */}
|
{/* Alert / Reminders */}
|
||||||
<div style={iconRow}>
|
<div style={iconRow}>
|
||||||
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
|
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
|
||||||
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
@ -714,23 +848,79 @@ export default function CalendarEventModal({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
reminders.map((reminder, idx) => (
|
reminders.map((reminder, idx) => (
|
||||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
<select
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
value={reminder.minutes}
|
<select
|
||||||
onChange={e => updateReminder(idx, parseInt(e.target.value))}
|
value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
|
||||||
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
|
onChange={e => updateReminder(idx, parseInt(e.target.value))}
|
||||||
style={{ ...inlineSelect, flex: 1 }}
|
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
|
||||||
>
|
style={{ ...inlineSelect, flex: 1 }}
|
||||||
{REMINDER_OPTIONS.map(opt => (
|
>
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
{buildReminderOptions().map(opt => (
|
||||||
))}
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
</select>
|
))}
|
||||||
<button
|
</select>
|
||||||
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
|
<button
|
||||||
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
|
onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
|
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
|
||||||
<X size={12} aria-hidden="true" />
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
|
||||||
</button>
|
<X size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Custom reminder editor row */}
|
||||||
|
{customEditorIdx === idx && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '4px',
|
||||||
|
paddingLeft: '0', background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
||||||
|
borderRadius: '6px', padding: '4px 6px',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={9999}
|
||||||
|
value={customAmount}
|
||||||
|
onChange={e => setCustomAmount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
aria-label={language === 'de' ? 'Erinnerungsmenge' : 'Reminder amount'}
|
||||||
|
style={{
|
||||||
|
width: '48px', textAlign: 'center', fontSize: '0.78rem',
|
||||||
|
background: 'var(--weekly-bg, #fff)', borderRadius: '5px',
|
||||||
|
padding: '2px 4px', border: '1px solid var(--weekly-border, #ddd)',
|
||||||
|
outline: 'none', color: 'var(--weekly-text)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={reminderUnit}
|
||||||
|
onChange={e => setReminderUnit(e.target.value as any)}
|
||||||
|
aria-label={language === 'de' ? 'Erinnerungseinheit' : 'Reminder unit'}
|
||||||
|
style={{ ...inlineSelect, flex: 1, fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
<option value="minutes">{language === 'de' ? 'Minuten' : 'minutes'}</option>
|
||||||
|
<option value="hours">{language === 'de' ? 'Stunden' : 'hours'}</option>
|
||||||
|
<option value="days">{language === 'de' ? 'Tage' : 'days'}</option>
|
||||||
|
</select>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: 'var(--weekly-text-light)', whiteSpace: 'nowrap' }}>
|
||||||
|
{language === 'de' ? 'vorher' : 'before'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => confirmCustomReminder(idx)}
|
||||||
|
title={language === 'de' ? 'Bestätigen' : 'Confirm'}
|
||||||
|
style={{
|
||||||
|
background: '#3b82f6', border: 'none', borderRadius: '5px',
|
||||||
|
cursor: 'pointer', padding: '3px 7px', display: 'flex', alignItems: 'center',
|
||||||
|
color: '#fff', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCustomEditorIdx(null)}
|
||||||
|
title={language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.6 }}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
94
src/components/FlagIcon.tsx
Normal file
94
src/components/FlagIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import React, { useState, useRef, useEffect, Suspense } from "react";
|
import React, { useState, useRef, useEffect, Suspense } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
|
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
|
||||||
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||||
import MdiIcon from "@mdi/react";
|
import MdiIcon from "@mdi/react";
|
||||||
import { allIcons } from "./iconRegistry";
|
import { allIcons } from "./iconRegistry";
|
||||||
import { Task, KanbanStage } from "./WeeklyView";
|
import { Task, KanbanStage, getPriorityBadge } from "./WeeklyView";
|
||||||
|
|
||||||
interface GridTaskBlockProps {
|
interface GridTaskBlockProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@ -39,6 +39,8 @@ interface GridTaskBlockProps {
|
|||||||
workingHoursStart: number;
|
workingHoursStart: number;
|
||||||
showTaskCheckboxes?: boolean;
|
showTaskCheckboxes?: boolean;
|
||||||
showProjectIcons?: boolean;
|
showProjectIcons?: boolean;
|
||||||
|
showPriorityIcons?: boolean;
|
||||||
|
priorityStyle?: string;
|
||||||
projects?: any[];
|
projects?: any[];
|
||||||
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
||||||
kanbanStages?: KanbanStage[];
|
kanbanStages?: KanbanStage[];
|
||||||
@ -76,6 +78,8 @@ export function GridTaskBlock({
|
|||||||
workingHoursStart,
|
workingHoursStart,
|
||||||
showTaskCheckboxes,
|
showTaskCheckboxes,
|
||||||
showProjectIcons,
|
showProjectIcons,
|
||||||
|
showPriorityIcons = true,
|
||||||
|
priorityStyle = "eisenhower",
|
||||||
projects,
|
projects,
|
||||||
onProjectAssign,
|
onProjectAssign,
|
||||||
kanbanStages = [],
|
kanbanStages = [],
|
||||||
@ -263,10 +267,10 @@ export function GridTaskBlock({
|
|||||||
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
|
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
|
||||||
if (stageColor) return `4px solid ${stageColor}`;
|
if (stageColor) return `4px solid ${stageColor}`;
|
||||||
if (task.project?.color) return `4px solid ${task.project.color}`;
|
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",
|
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",
|
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@ -295,7 +299,7 @@ export function GridTaskBlock({
|
|||||||
if (editingTaskId !== task.id) toggleTask(task.id);
|
if (editingTaskId !== task.id) toggleTask(task.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
|
||||||
{editingTaskId === task.id ? (
|
{editingTaskId === task.id ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@ -325,7 +329,7 @@ export function GridTaskBlock({
|
|||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "center",
|
||||||
gap: "3px",
|
gap: "3px",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
@ -337,6 +341,37 @@ export function GridTaskBlock({
|
|||||||
setEditingTaskId(task.id);
|
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 && (
|
{showTaskCheckboxes && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -345,26 +380,11 @@ export function GridTaskBlock({
|
|||||||
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="task-checkbox flex-shrink-0"
|
className="task-checkbox flex-shrink-0"
|
||||||
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }}
|
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", flexShrink: 0, accentColor: "#FFF" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
|
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
|
||||||
<span style={{ marginRight: "4px" }}>{showProjectIcons && task.project && (() => {
|
<span style={{ marginRight: "4px" }}>{task.title}</span>
|
||||||
const rawIcon = task.project!.icon || "";
|
|
||||||
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
|
|
||||||
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
|
|
||||||
: rawIcon;
|
|
||||||
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
|
|
||||||
const iconStyle = { marginRight: "4px", verticalAlign: "middle" } as const;
|
|
||||||
if (found) {
|
|
||||||
return found.type === "fa"
|
|
||||||
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />
|
|
||||||
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ ...iconStyle, display: "inline-block" }} />;
|
|
||||||
}
|
|
||||||
return task.project!.icon
|
|
||||||
? <span style={{ fontSize: "11px", ...iconStyle }}>{task.project!.icon}</span>
|
|
||||||
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />;
|
|
||||||
})()}{task.title}</span>
|
|
||||||
{/* Subtask indicator */}
|
{/* Subtask indicator */}
|
||||||
{task.subTasks && task.subTasks.length > 0 && (() => {
|
{task.subTasks && task.subTasks.length > 0 && (() => {
|
||||||
const completed = task.subTasks.filter(s => s.completed).length;
|
const completed = task.subTasks.filter(s => s.completed).length;
|
||||||
@ -476,53 +496,42 @@ export function GridTaskBlock({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{weatherEnabled && (() => {
|
</span>
|
||||||
const provider = task.externalProvider
|
</span>
|
||||||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
)}
|
||||||
if (!provider) return null;
|
{editingTaskId !== task.id && (() => {
|
||||||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
const rolling = task.isRolling && !task.completed;
|
||||||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
const provider = task.externalProvider || (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
if (!rolling && !provider) return null;
|
||||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
||||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
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];
|
const info = iconMap[provider];
|
||||||
if (!info) return null;
|
if (!info) return null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span className="flex-shrink-0" aria-label={`Synced with ${info.label}`} style={{ display: "inline-flex", alignItems: "center", opacity: 0.55 }}>
|
||||||
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 }} />
|
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
);
|
||||||
)}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{!weatherEnabled && (() => {
|
|
||||||
const provider = task.externalProvider
|
|
||||||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
|
||||||
if (!provider) return null;
|
|
||||||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
|
||||||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
|
||||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
|
||||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
|
||||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
|
||||||
};
|
|
||||||
const info = iconMap[provider];
|
|
||||||
if (!info) return null;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-label={`Synced with ${info.label}`}
|
|
||||||
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="task-actions"
|
className="task-actions"
|
||||||
@ -606,13 +615,21 @@ export function GridTaskBlock({
|
|||||||
aria-expanded={showProjectPicker}
|
aria-expanded={showProjectPicker}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
>
|
>
|
||||||
<Circle
|
{task.project ? (() => {
|
||||||
size={12}
|
const rawIcon = task.project.icon || "";
|
||||||
aria-hidden="true"
|
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
|
||||||
fill={task.project?.color || "none"}
|
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
|
||||||
stroke={task.project?.color || "currentColor"}
|
: rawIcon;
|
||||||
strokeWidth={2}
|
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>
|
</button>
|
||||||
{showProjectPicker && (
|
{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" }}>
|
<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);
|
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}
|
{p.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { faGoogle, faApple, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
import { faGoogle, faApple, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
||||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||||
import FontPicker from "./FontPicker";
|
import FontPicker from "./FontPicker";
|
||||||
|
import FlagIcon from "./FlagIcon";
|
||||||
|
|
||||||
interface OnboardingWizardProps {
|
interface OnboardingWizardProps {
|
||||||
profile: any;
|
profile: any;
|
||||||
@ -755,7 +756,7 @@ export default function OnboardingWizard({
|
|||||||
transition: "all 0.15s",
|
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}
|
{lang.label}
|
||||||
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
|
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -70,6 +70,8 @@ interface PriorityViewProps {
|
|||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
onUpdateTask: (id: string, fields: Partial<PriorityTask>) => Promise<void>;
|
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 }> = {
|
const ABCDE_LABELS: Record<string, { label: string; desc: string; color: string; bg: string }> = {
|
||||||
@ -123,8 +125,14 @@ export default function PriorityView({
|
|||||||
darkMode,
|
darkMode,
|
||||||
language = "en",
|
language = "en",
|
||||||
onUpdateTask,
|
onUpdateTask,
|
||||||
|
initialMethod,
|
||||||
|
onMethodChange,
|
||||||
}: PriorityViewProps) {
|
}: 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 [filterProject, setFilterProject] = useState("");
|
||||||
const [filterList, setFilterList] = useState("");
|
const [filterList, setFilterList] = useState("");
|
||||||
const [filterTimespan, setFilterTimespan] = useState("all");
|
const [filterTimespan, setFilterTimespan] = useState("all");
|
||||||
@ -135,7 +143,15 @@ export default function PriorityView({
|
|||||||
const [delegateTo, setDelegateTo] = useState("");
|
const [delegateTo, setDelegateTo] = useState("");
|
||||||
const [delegateNote, setDelegateNote] = useState("");
|
const [delegateNote, setDelegateNote] = useState("");
|
||||||
const [delegateType, setDelegateType] = useState<"person" | "ai">("person");
|
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 [showFilters, setShowFilters] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
@ -251,12 +267,21 @@ export default function PriorityView({
|
|||||||
const toggleIvyLee = useCallback((id: string) => {
|
const toggleIvyLee = useCallback((id: string) => {
|
||||||
setIvyLeeSelected((prev) => {
|
setIvyLeeSelected((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) { next.delete(id); return next; }
|
const removing = next.has(id);
|
||||||
if (next.size >= 6) return prev; // max 6
|
if (removing) {
|
||||||
next.add(id);
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [onUpdateTask]);
|
||||||
|
|
||||||
const openDelegate = useCallback((task: PriorityTask) => {
|
const openDelegate = useCallback((task: PriorityTask) => {
|
||||||
setDelegateModal(task);
|
setDelegateModal(task);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"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 {
|
interface QuickSettingsProps {
|
||||||
fontSize: string;
|
fontSize: string;
|
||||||
@ -12,6 +12,8 @@ interface QuickSettingsProps {
|
|||||||
onStartDayOffsetChange: (offset: number) => void;
|
onStartDayOffsetChange: (offset: number) => void;
|
||||||
showLines: boolean;
|
showLines: boolean;
|
||||||
onShowLinesChange: (show: boolean) => void;
|
onShowLinesChange: (show: boolean) => void;
|
||||||
|
showPriorityIcons?: boolean;
|
||||||
|
onShowPriorityIconsChange?: (show: boolean) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
darkMode?: boolean;
|
darkMode?: boolean;
|
||||||
@ -28,6 +30,8 @@ export default function QuickSettingsSidebar({
|
|||||||
onStartDayOffsetChange,
|
onStartDayOffsetChange,
|
||||||
showLines,
|
showLines,
|
||||||
onShowLinesChange,
|
onShowLinesChange,
|
||||||
|
showPriorityIcons,
|
||||||
|
onShowPriorityIconsChange,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
darkMode,
|
darkMode,
|
||||||
@ -198,6 +202,17 @@ export default function QuickSettingsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<Toggle checked={showLines} onChange={onShowLinesChange} />
|
<Toggle checked={showLines} onChange={onShowLinesChange} />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
201
src/components/SearchableDropdown.tsx
Normal file
201
src/components/SearchableDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"
|
|||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
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 CalendarSyncRulesPanel from "./CalendarSyncRulesPanel";
|
||||||
import { ViewStyle, KanbanStage, Task } from "./WeeklyView";
|
import { ViewStyle, KanbanStage, Task } from "./WeeklyView";
|
||||||
import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants";
|
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 { EXPORT_FIELDS, type ExportFieldKey } from "../app/api/user/export/route";
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
|
Briefcase,
|
||||||
Calendar,
|
Calendar,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Check,
|
||||||
|
FolderOpen,
|
||||||
Globe,
|
Globe,
|
||||||
Info,
|
Info,
|
||||||
Kanban,
|
Kanban,
|
||||||
Link,
|
Link,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Palette,
|
Palette,
|
||||||
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
@ -28,6 +32,376 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} 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 {
|
export interface SomedayList {
|
||||||
id: string;
|
id: string;
|
||||||
@ -193,7 +567,7 @@ interface SettingsSidebarProps {
|
|||||||
fetchAvailableTaskLists: (
|
fetchAvailableTaskLists: (
|
||||||
provider: "google" | "apple" | "outlook" | "synology",
|
provider: "google" | "apple" | "outlook" | "synology",
|
||||||
) => Promise<void>;
|
) => 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 }[];
|
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||||||
onProjectsChanged: () => void;
|
onProjectsChanged: () => void;
|
||||||
kanbanStages: KanbanStage[];
|
kanbanStages: KanbanStage[];
|
||||||
@ -326,7 +700,7 @@ function SettingsSidebar({
|
|||||||
onRunSetupAssistant,
|
onRunSetupAssistant,
|
||||||
}: SettingsSidebarProps) {
|
}: SettingsSidebarProps) {
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync"
|
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync" | "projects"
|
||||||
>(initialTab || "general");
|
>(initialTab || "general");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
@ -819,11 +1193,11 @@ function SettingsSidebar({
|
|||||||
className="weekly-settings-tabs"
|
className="weekly-settings-tabs"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "space-around",
|
||||||
flexWrap: "wrap",
|
flexWrap: "nowrap",
|
||||||
gap: "4px",
|
gap: "0",
|
||||||
borderBottom: "1px solid var(--weekly-border, #eee)",
|
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: "localisation", icon: <Globe size={18} />, label: t.localisation },
|
||||||
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
||||||
{ key: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" },
|
{ 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: "account", icon: <User size={18} />, label: t.account },
|
||||||
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
|
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
|
||||||
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
|
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
|
||||||
@ -842,7 +1217,7 @@ function SettingsSidebar({
|
|||||||
title={tab.label}
|
title={tab.label}
|
||||||
className="settings-tab-btn"
|
className="settings-tab-btn"
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 14px",
|
padding: "10px 8px",
|
||||||
borderBottom:
|
borderBottom:
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? "2px solid var(--weekly-text, black)"
|
? "2px solid var(--weekly-text, black)"
|
||||||
@ -880,6 +1255,74 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{/* ── General settings (not view-specific) ── */}
|
{/* ── General settings (not view-specific) ── */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingBottom: "16px", borderBottom: "1px solid var(--weekly-border, #e5e7eb)" }}>
|
<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 */}
|
{/* Header Display */}
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
<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>
|
<label htmlFor="showProjectIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t.showProjectIcons}</label>
|
||||||
</div>
|
</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" && (
|
{viewStyle !== "kanban" && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
<input type="checkbox" id="protectEventTimes" checked={profile.protectEventTimes || false}
|
<input type="checkbox" id="protectEventTimes" checked={profile.protectEventTimes || false}
|
||||||
@ -1543,23 +2017,17 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{t.language}
|
{t.language}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<SearchableDropdown
|
||||||
value={profile.language || "de"}
|
value={profile.language || "de"}
|
||||||
onChange={(e) => saveField("language", e.target.value)}
|
onChange={(v) => saveField("language", v)}
|
||||||
className="weekly-input"
|
options={[
|
||||||
style={{
|
{ value: "en", label: "English", leading: <FlagIcon code="en" width={20} height={14} /> },
|
||||||
width: "100%",
|
{ value: "de", label: "Deutsch", leading: <FlagIcon code="de" width={20} height={14} /> },
|
||||||
padding: "8px",
|
{ value: "fr", label: "Français", leading: <FlagIcon code="fr" width={20} height={14} /> },
|
||||||
border: "1px solid #ddd",
|
{ value: "es", label: "Español", leading: <FlagIcon code="es" width={20} height={14} /> },
|
||||||
borderRadius: "4px",
|
{ value: "it", label: "Italiano", leading: <FlagIcon code="it" width={20} height={14} /> },
|
||||||
}}
|
]}
|
||||||
>
|
/>
|
||||||
<option value="en">🇬🇧 English</option>
|
|
||||||
<option value="de">🇩🇪 Deutsch</option>
|
|
||||||
<option value="fr">🇫🇷 Français</option>
|
|
||||||
<option value="es">🇪🇸 Español</option>
|
|
||||||
<option value="it">🇮🇹 Italiano</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -1573,19 +2041,37 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{t.timezone}
|
{t.timezone}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<SearchableDropdown
|
||||||
style={{
|
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||||
padding: "8px",
|
onChange={(v) => saveField("timezone", v)}
|
||||||
fontSize: "0.9rem",
|
searchable
|
||||||
border: "1px solid var(--weekly-settings-input-border)",
|
searchPlaceholder={profile.language === "de" ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||||||
borderRadius: "4px",
|
emptyText={profile.language === "de" ? "Keine Treffer" : "No matches"}
|
||||||
background: "var(--weekly-settings-input-bg)",
|
options={[...TIMEZONE_OPTIONS]
|
||||||
color: "var(--weekly-settings-text)",
|
.sort((a, b) => {
|
||||||
opacity: 0.8,
|
const oa = getOffsetMinutes(a.zone);
|
||||||
}}
|
const ob = getOffsetMinutes(b.zone);
|
||||||
>
|
if (oa !== ob) return oa - ob;
|
||||||
{Intl.DateTimeFormat().resolvedOptions().timeZone}
|
return a.code.localeCompare(b.code);
|
||||||
</div>
|
})
|
||||||
|
.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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -3468,6 +3954,55 @@ function SettingsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* All styling settings auto-save */}
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === "motivation" ? (
|
) : activeTab === "motivation" ? (
|
||||||
@ -3928,6 +4463,165 @@ function SettingsSidebar({
|
|||||||
connections={connections}
|
connections={connections}
|
||||||
t={t}
|
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" ? (
|
) : activeTab === "about" ? (
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
GoogleProvider({
|
GoogleProvider({
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||||
allowDangerousEmailAccountLinking: true,
|
allowDangerousEmailAccountLinking: false,
|
||||||
authorization: {
|
authorization: {
|
||||||
params: {
|
params: {
|
||||||
prompt: "consent",
|
prompt: "consent",
|
||||||
@ -26,13 +26,13 @@ export const authOptions: NextAuthOptions = {
|
|||||||
AppleProvider({
|
AppleProvider({
|
||||||
clientId: process.env.APPLE_ID || "",
|
clientId: process.env.APPLE_ID || "",
|
||||||
clientSecret: process.env.APPLE_SECRET || "",
|
clientSecret: process.env.APPLE_SECRET || "",
|
||||||
allowDangerousEmailAccountLinking: true,
|
allowDangerousEmailAccountLinking: false,
|
||||||
}),
|
}),
|
||||||
AzureADProvider({
|
AzureADProvider({
|
||||||
clientId: process.env.MICROSOFT_CLIENT_ID || "",
|
clientId: process.env.MICROSOFT_CLIENT_ID || "",
|
||||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
|
||||||
tenantId: "common",
|
tenantId: "common",
|
||||||
allowDangerousEmailAccountLinking: true,
|
allowDangerousEmailAccountLinking: false,
|
||||||
authorization: {
|
authorization: {
|
||||||
params: {
|
params: {
|
||||||
prompt: "consent",
|
prompt: "consent",
|
||||||
@ -85,7 +85,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
debug: true,
|
debug: process.env.NODE_ENV === 'development',
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
|||||||
@ -78,6 +78,7 @@ export async function readCachedEvents(
|
|||||||
calendarId: row.calendarId,
|
calendarId: row.calendarId,
|
||||||
calendarTitle: row.calendarTitle,
|
calendarTitle: row.calendarTitle,
|
||||||
calendarColor: row.calendarColor,
|
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,
|
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
|
||||||
endDate: ev.end.date ?? null,
|
endDate: ev.end.date ?? null,
|
||||||
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
|
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
|
||||||
|
busyStatus: ev.busyStatus ?? null,
|
||||||
weekStart,
|
weekStart,
|
||||||
syncedAt: now,
|
syncedAt: now,
|
||||||
}));
|
}));
|
||||||
@ -183,6 +185,7 @@ export async function upsertCachedEvent(
|
|||||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||||
endDate: event.end.date ?? null,
|
endDate: event.end.date ?? null,
|
||||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||||
|
busyStatus: event.busyStatus ?? null,
|
||||||
weekStart,
|
weekStart,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
@ -200,6 +203,7 @@ export async function upsertCachedEvent(
|
|||||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||||
endDate: event.end.date ?? null,
|
endDate: event.end.date ?? null,
|
||||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||||
|
busyStatus: event.busyStatus ?? null,
|
||||||
weekStart,
|
weekStart,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
@ -214,7 +218,22 @@ export async function deleteCachedEvent(
|
|||||||
externalId: string,
|
externalId: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
): Promise<void> {
|
): 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({
|
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}::` } },
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -863,8 +863,13 @@ export const createCalendarEvent = async (
|
|||||||
source: 'outlook',
|
source: 'outlook',
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: '',
|
calendarTitle: '',
|
||||||
isRecurring: !!event.recurrence,
|
isRecurring: createdEvent.isRecurring ?? !!event.recurrence,
|
||||||
recurringEventId: event.recurrence ? createdEvent.id : undefined,
|
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;
|
} as CalendarEvent;
|
||||||
} else if (connection.provider === 'apple') {
|
} else if (connection.provider === 'apple') {
|
||||||
const [email, appPassword] = connection.accessToken.split(':');
|
const [email, appPassword] = connection.accessToken.split(':');
|
||||||
@ -1114,6 +1119,13 @@ export const updateCalendarEvent = async (
|
|||||||
source: 'outlook',
|
source: 'outlook',
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: '',
|
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;
|
} as CalendarEvent;
|
||||||
} else if (connection.provider === 'apple') {
|
} else if (connection.provider === 'apple') {
|
||||||
const [email, appPassword] = connection.accessToken.split(':');
|
const [email, appPassword] = connection.accessToken.split(':');
|
||||||
@ -1342,6 +1354,16 @@ export const deleteCalendarEvent = async (
|
|||||||
} else if (deleteMode === 'all' || !hasInstanceId) {
|
} else if (deleteMode === 'all' || !hasInstanceId) {
|
||||||
// Delete the entire series (use series master ID)
|
// Delete the entire series (use series master ID)
|
||||||
await deleteOutlookEvent(accessToken, calendarId, seriesMasterId);
|
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 {
|
} else {
|
||||||
// 'future' or 'past' — Outlook doesn't support partial series delete easily
|
// 'future' or 'past' — Outlook doesn't support partial series delete easily
|
||||||
// Fall back to deleting the series
|
// Fall back to deleting the series
|
||||||
|
|||||||
102
src/lib/email.ts
102
src/lib/email.ts
@ -1,4 +1,5 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
||||||
|
|
||||||
let _transporter: nodemailer.Transporter | null = null;
|
let _transporter: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
@ -26,10 +27,16 @@ function getTransporter() {
|
|||||||
export async function sendVerificationEmail(
|
export async function sendVerificationEmail(
|
||||||
email: string,
|
email: string,
|
||||||
code: string,
|
code: string,
|
||||||
token: string
|
token: string,
|
||||||
|
language?: string | null,
|
||||||
) {
|
) {
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
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 = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -43,59 +50,32 @@ export async function sendVerificationEmail(
|
|||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<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);">
|
<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>
|
<tr>
|
||||||
<td style="padding: 32px 40px 16px; text-align: center;">
|
<td style="padding: 32px 40px 16px; text-align: center;">
|
||||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
|
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
|
||||||
My Weekly ToDo's
|
|
||||||
</h1>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 16px 40px;">
|
<td style="padding: 16px 40px;">
|
||||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
|
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.welcome}</p>
|
||||||
Welcome! Please verify your email address to complete your registration.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Code Box -->
|
|
||||||
<div style="background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px; padding: 24px; text-align: center; margin: 0 0 24px;">
|
<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;">
|
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">${t.yourCode}</p>
|
||||||
Your verification code
|
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">${code}</p>
|
||||||
</p>
|
|
||||||
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">
|
|
||||||
${code}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">${t.orClick}</p>
|
||||||
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">
|
|
||||||
Or click the button below to verify instantly:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Button -->
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 0 0 24px;">
|
<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);">
|
<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>
|
||||||
✓ Verify Email
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
|
||||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
|
||||||
This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
|
<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;">
|
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
|
||||||
Simple. Beautiful. Yours.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@ -108,23 +88,22 @@ export async function sendVerificationEmail(
|
|||||||
|
|
||||||
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
|
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
|
||||||
console.log(`[EMAIL] Sending verification email to ${email} from ${from}`);
|
console.log(`[EMAIL] Sending verification email to ${email} from ${from}`);
|
||||||
console.log(`\n======================================================`);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log(`[DEV VERIFICATION LINK]:\n${verifyLink}`);
|
console.log(`\n======================================================`);
|
||||||
console.log(`======================================================\n`);
|
console.log(`[DEV VERIFICATION LINK]:\n${verifyLink}`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 15 second timeout for SMTP
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
||||||
);
|
);
|
||||||
|
|
||||||
const mailPromise = getTransporter().sendMail({
|
const mailPromise = getTransporter().sendMail({
|
||||||
from,
|
from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `${code} – Verify your email for My Weekly ToDo's`,
|
subject: t.subject(code),
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
||||||
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
|
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
|
||||||
} catch (err: any) {
|
} 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 baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||||
const resetLink = `${baseUrl}/auth/reset-password?token=${token}`;
|
const resetLink = `${baseUrl}/auth/reset-password?token=${token}`;
|
||||||
|
const t = getResetCopy(language);
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<!DOCTYPE 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);">
|
<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>
|
<tr>
|
||||||
<td style="padding: 32px 40px 16px; text-align: center;">
|
<td style="padding: 32px 40px 16px; text-align: center;">
|
||||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
|
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
|
||||||
My Weekly ToDo's
|
|
||||||
</h1>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 16px 40px;">
|
<td style="padding: 16px 40px;">
|
||||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
|
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.intro}</p>
|
||||||
We received a request to reset your password. Click the button below to choose a new one.
|
|
||||||
</p>
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 0 0 24px;">
|
<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);">
|
<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>
|
||||||
Reset Password
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
|
||||||
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
|
<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;">
|
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
|
||||||
Simple. Beautiful. Yours.
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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>';
|
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
|
||||||
console.log(`[EMAIL] Sending password reset email to ${email} from ${from}`);
|
console.log(`[EMAIL] Sending password reset email to ${email} from ${from}`);
|
||||||
console.log(`\n======================================================`);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log(`[DEV RESET LINK]:\n${resetLink}`);
|
console.log(`\n======================================================`);
|
||||||
console.log(`======================================================\n`);
|
console.log(`[DEV RESET LINK]:\n${resetLink}`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
||||||
);
|
);
|
||||||
|
|
||||||
const mailPromise = getTransporter().sendMail({
|
const mailPromise = getTransporter().sendMail({
|
||||||
from,
|
from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `Password Reset – My Weekly ToDo's`,
|
subject: t.subject,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
||||||
console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`);
|
console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
135
src/lib/emailTemplates.ts
Normal file
135
src/lib/emailTemplates.ts
Normal 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)];
|
||||||
|
}
|
||||||
@ -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)
|
* Fetch tasks from a specific list including completed ones (for sync)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -87,6 +87,32 @@ export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTo
|
|||||||
return (data.value || []) as MicrosoftTodoList[];
|
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).
|
* Fetch active tasks from a specific To-Do list (for import).
|
||||||
*/
|
*/
|
||||||
@ -158,6 +184,78 @@ export const fetchMsTodoTasksForSync = async (
|
|||||||
return allTasks;
|
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.
|
* Create a new Microsoft To-Do task in a list.
|
||||||
*/
|
*/
|
||||||
@ -168,6 +266,7 @@ export const createMsTodoTask = async (
|
|||||||
title: string;
|
title: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
dueDateTime?: string;
|
dueDateTime?: string;
|
||||||
|
importance?: 'low' | 'normal' | 'high';
|
||||||
}
|
}
|
||||||
): Promise<MicrosoftTodoTask> => {
|
): Promise<MicrosoftTodoTask> => {
|
||||||
const requestBody: any = { title: taskData.title };
|
const requestBody: any = { title: taskData.title };
|
||||||
@ -181,6 +280,9 @@ export const createMsTodoTask = async (
|
|||||||
timeZone: 'UTC'
|
timeZone: 'UTC'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (taskData.importance) {
|
||||||
|
requestBody.importance = taskData.importance;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`,
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`,
|
||||||
@ -214,6 +316,7 @@ export const updateMsTodoTask = async (
|
|||||||
body?: string;
|
body?: string;
|
||||||
status?: 'notStarted' | 'completed';
|
status?: 'notStarted' | 'completed';
|
||||||
dueDateTime?: string | null;
|
dueDateTime?: string | null;
|
||||||
|
importance?: 'low' | 'normal' | 'high';
|
||||||
}
|
}
|
||||||
): Promise<MicrosoftTodoTask> => {
|
): Promise<MicrosoftTodoTask> => {
|
||||||
const body: any = {};
|
const body: any = {};
|
||||||
@ -236,6 +339,7 @@ export const updateMsTodoTask = async (
|
|||||||
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
|
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
if (updates.importance !== undefined) body.importance = updates.importance;
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||||
|
|||||||
@ -201,54 +201,7 @@ export const getUpcomingEvents = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.value.map((event: any) => {
|
return data.value.map((event: any) => mapOutlookEventResponse(event));
|
||||||
// Map Outlook showAs to our busyStatus
|
|
||||||
const showAsMap: Record<string, string> = {
|
|
||||||
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
|
|
||||||
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
|
|
||||||
};
|
|
||||||
// Map Outlook sensitivity to our visibility
|
|
||||||
const sensitivityMap: Record<string, string> = {
|
|
||||||
'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Outlook returns dateTime without Z suffix even when timeZone is UTC.
|
|
||||||
// Append Z so JS Date parsing treats it as UTC (not local time).
|
|
||||||
const fixUtc = (dt: string, tz: string) =>
|
|
||||||
dt && tz === 'UTC' && !dt.endsWith('Z') ? dt + 'Z' : dt;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
|
|
||||||
summary: event.subject,
|
|
||||||
description: event.body?.content || event.bodyPreview,
|
|
||||||
start: {
|
|
||||||
dateTime: fixUtc(event.start.dateTime, event.start.timeZone),
|
|
||||||
timeZone: event.start.timeZone
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
dateTime: fixUtc(event.end.dateTime, event.end.timeZone),
|
|
||||||
timeZone: event.end.timeZone
|
|
||||||
},
|
|
||||||
location: event.location?.displayName,
|
|
||||||
htmlLink: event.webLink,
|
|
||||||
allDay: event.isAllDay,
|
|
||||||
recurringEventId: event.seriesMasterId || undefined,
|
|
||||||
isRecurring: event.type === 'occurrence' || event.type === 'exception' || event.type === 'seriesMaster',
|
|
||||||
reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null
|
|
||||||
? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }]
|
|
||||||
: undefined,
|
|
||||||
busyStatus: showAsMap[event.showAs] || undefined,
|
|
||||||
visibility: sensitivityMap[event.sensitivity] || undefined,
|
|
||||||
attendees: event.attendees?.map((a: any) => ({
|
|
||||||
email: a.emailAddress?.address,
|
|
||||||
displayName: a.emailAddress?.name,
|
|
||||||
responseStatus: a.status?.response === 'accepted' ? 'accepted'
|
|
||||||
: a.status?.response === 'declined' ? 'declined'
|
|
||||||
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
|
|
||||||
: 'needsAction',
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureTimeZone = (dateTimeObj: any) => {
|
const ensureTimeZone = (dateTimeObj: any) => {
|
||||||
@ -283,6 +236,45 @@ const normalizeOutlookDateTime = (dtObj: any) => {
|
|||||||
return { dateTime: dt, timeZone: dtObj.timeZone };
|
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 (
|
export const createEvent = async (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
calendarId: string,
|
calendarId: string,
|
||||||
@ -336,15 +328,7 @@ export const createEvent = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await response.json();
|
const created = await response.json();
|
||||||
return {
|
return mapOutlookEventResponse(created);
|
||||||
id: created.id,
|
|
||||||
summary: created.subject,
|
|
||||||
description: created.bodyPreview,
|
|
||||||
start: normalizeOutlookDateTime(created.start),
|
|
||||||
end: normalizeOutlookDateTime(created.end),
|
|
||||||
location: created.location?.displayName,
|
|
||||||
allDay: created.isAllDay
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -419,15 +403,7 @@ export const updateEvent = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await response.json();
|
const updated = await response.json();
|
||||||
return {
|
return mapOutlookEventResponse(updated);
|
||||||
id: updated.id,
|
|
||||||
summary: updated.subject,
|
|
||||||
description: updated.bodyPreview,
|
|
||||||
start: normalizeOutlookDateTime(updated.start),
|
|
||||||
end: normalizeOutlookDateTime(updated.end),
|
|
||||||
location: updated.location?.displayName,
|
|
||||||
allDay: updated.isAllDay
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
97
src/lib/timezones.ts
Normal file
97
src/lib/timezones.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2017",
|
"target": "es2017",
|
||||||
"downlevelIteration": true,
|
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
@ -14,7 +13,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user