Compare commits
63 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 | |||
| 3e19ebe85a | |||
| aa4c6a085d | |||
| cf7e138bc9 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -41,3 +41,9 @@ prisma/dev.db-journal
|
||||
.next/
|
||||
|
||||
certificates
|
||||
|
||||
# Design reference files (not tracked)
|
||||
docs/design/
|
||||
|
||||
# Dokumentationen (nicht tracken)
|
||||
APP_ERKLÄRUNG.md
|
||||
20
CHANGELOG.md
Normal file
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.
|
||||
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",
|
||||
"version": "1.92.0",
|
||||
"version": "1.104.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.92.0",
|
||||
"version": "1.104.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.99.2",
|
||||
"version": "1.113.1",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -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;
|
||||
@ -116,6 +116,8 @@ model User {
|
||||
weatherLon Float?
|
||||
viewSettings Json?
|
||||
weatherRecentCities Json?
|
||||
customCssVars Json?
|
||||
customCss String? @db.Text
|
||||
hasCompletedOnboarding Boolean @default(true)
|
||||
showCalendarProviderIcon Boolean @default(false)
|
||||
headerCurrentDayFormat String? @default("DDD, DD. MMMM YYYY")
|
||||
@ -235,6 +237,7 @@ model SomedayList {
|
||||
externalId String?
|
||||
externalProvider String?
|
||||
lastSyncedAt DateTime?
|
||||
syncDeltaToken String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tasks Task[]
|
||||
|
||||
@ -279,6 +282,7 @@ model CachedCalendarEvent {
|
||||
recurringEventId String?
|
||||
isRecurring Boolean @default(false)
|
||||
reminders Json?
|
||||
busyStatus String?
|
||||
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export async function POST(request: NextRequest) {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
emailVerificationExpires: true,
|
||||
language: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email, code, token);
|
||||
await sendVerificationEmail(email, code, token, user.language);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to resend verification email:', emailError);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -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.' });
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { name, email, password } = await request.json();
|
||||
const body = await request.json();
|
||||
const { name, email, password } = body;
|
||||
// Prefer the explicit language from the form (filled in by the signup
|
||||
// page using navigator.language) and fall back to Accept-Language.
|
||||
const language = body.language
|
||||
|| languageFromAcceptLanguage(request.headers.get('accept-language'));
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
@ -42,11 +46,12 @@ export async function POST(request: NextRequest) {
|
||||
emailVerificationCode: code,
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpires: expires,
|
||||
language,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email, code, token);
|
||||
await sendVerificationEmail(email, code, token, language);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
}
|
||||
@ -77,6 +82,7 @@ export async function POST(request: NextRequest) {
|
||||
name: name || undefined,
|
||||
email,
|
||||
passwordHash,
|
||||
language,
|
||||
emailVerificationCode: code,
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpires: expires,
|
||||
@ -92,7 +98,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await sendVerificationEmail(email, code, token);
|
||||
await sendVerificationEmail(email, code, token, language);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
// User is created but email failed — they can use "resend" later
|
||||
|
||||
@ -78,31 +78,58 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET: Verify via one-click link
|
||||
// GET: Legacy one-click link.
|
||||
//
|
||||
// This used to verify-and-redirect on a single GET, but corporate inbox
|
||||
// scanners (M365 Safe Links, etc.) pre-fetch URLs to scan them — which
|
||||
// silently consumed the one-shot token before the recipient ever saw the
|
||||
// email (the bug behind point #2). New emails point straight at the client
|
||||
// page; this handler only redirects there for backward compatibility with
|
||||
// emails already in transit.
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
const target = new URL('/auth/verify-email', request.url);
|
||||
if (token) target.searchParams.set('token', token);
|
||||
return NextResponse.redirect(target);
|
||||
}
|
||||
|
||||
// PATCH: Verify via token from the email link (called by the client page after
|
||||
// the user actually clicks the button — survives scanner pre-fetches).
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const { token } = await request.json();
|
||||
if (!token) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/verify-email?error=missing_token', request.url)
|
||||
);
|
||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpires: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
emailVerificationExpires: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Friendly path: token already consumed but the email is verified — treat as success.
|
||||
if (!user) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/verify-email?error=invalid_token', request.url)
|
||||
return NextResponse.json({ error: 'Invalid token. It may have already been used.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return NextResponse.json({ message: 'Email already verified', email: user.email });
|
||||
}
|
||||
|
||||
if (!user.emailVerificationExpires || new Date() > user.emailVerificationExpires) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Verification link expired. Please request a new code.', email: user.email },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the user
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
@ -114,13 +141,9 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/login?verified=true', request.url)
|
||||
);
|
||||
return NextResponse.json({ message: 'Email verified successfully', email: user.email });
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/verify-email?error=server_error', request.url)
|
||||
);
|
||||
return NextResponse.json({ error: 'Failed to verify email' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -3,9 +3,7 @@ import { validateCredentials } from '@/lib/apple-calendar';
|
||||
import { CalendarConnection } from '@/lib/calendar-sync';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
|
||||
@ -2,12 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { google } from 'googleapis';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Google Calendar OAuth callback endpoint
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getUserDatabases } from '@/lib/notion-calendar';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@ -2,9 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { validateCredentials } from '@/lib/synology-calendar';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
|
||||
@ -127,7 +127,16 @@ export async function GET(req: Request) {
|
||||
};
|
||||
|
||||
// Try each user-configured URL first
|
||||
const userUrls: string[] = (user as any)?.quoteSourceUrls?.filter(Boolean) || [];
|
||||
const PRIVATE_IP = /^(localhost|127\.|0\.0\.0\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|::1$|fc00:|fe80:)/i;
|
||||
function isSafeQuoteUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(u.protocol)) return false;
|
||||
if (PRIVATE_IP.test(u.hostname)) return false;
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
const userUrls: string[] = ((user as any)?.quoteSourceUrls?.filter(Boolean) || []).filter(isSafeQuoteUrl);
|
||||
for (const url of userUrls) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||
|
||||
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 { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createGoogleClient, createGoogleTaskList } from '@/lib/google-tasks';
|
||||
import { createMsTodoList } from '@/lib/microsoft-todo';
|
||||
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||
import { createAppleReminderList } from '@/lib/apple-reminders';
|
||||
import { createSynologyReminderList } from '@/lib/synology-tasks';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { notifyUser } from '@/lib/sse';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function resolveUserId(email: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
@ -123,6 +121,8 @@ export async function DELETE(request: NextRequest) {
|
||||
const id = searchParams.get('id');
|
||||
// tasksOnly=true: soft-disconnect (keep list record with tab, just remove tasks + external link)
|
||||
const tasksOnly = searchParams.get('tasksOnly') === 'true';
|
||||
// deleteExternal=true: also remove the list from the connected provider (Outlook/Google)
|
||||
const deleteExternal = searchParams.get('deleteExternal') === 'true';
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@ -143,6 +143,33 @@ export async function DELETE(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If asked to remove the list from the external provider too, do so first
|
||||
// (so a Graph/API failure doesn't orphan the local state).
|
||||
let externalDeleteFailed = false;
|
||||
if (deleteExternal && list.externalId && list.externalProvider) {
|
||||
try {
|
||||
if (list.externalProvider === 'outlook') {
|
||||
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
|
||||
const { deleteMsTodoList } = await import('@/lib/microsoft-todo');
|
||||
const token = await getOutlookAccessToken(userId);
|
||||
if (token) await deleteMsTodoList(token, list.externalId);
|
||||
} else if (list.externalProvider === 'google') {
|
||||
const { createGoogleClient, deleteGoogleTaskList } = await import('@/lib/google-tasks');
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId, provider: { in: ['google-calendar', 'google'] } }
|
||||
});
|
||||
if (account?.access_token) {
|
||||
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||
await deleteGoogleTaskList(client, list.externalId);
|
||||
}
|
||||
}
|
||||
// Note: Synology task list deletion not yet implemented at the provider level.
|
||||
} catch (extErr) {
|
||||
console.error('Failed to delete external list, proceeding with local cleanup:', extErr);
|
||||
externalDeleteFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-delete tasks in this list (they can be recovered from trash)
|
||||
await prisma.task.updateMany({
|
||||
where: { somedayListId: id },
|
||||
@ -164,7 +191,7 @@ export async function DELETE(request: NextRequest) {
|
||||
});
|
||||
|
||||
notifyUser(userId, "list-changed", { action: "deleted" });
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true, externalDeleteFailed });
|
||||
} catch (error) {
|
||||
console.error('Error deleting someday list:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -2,13 +2,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
||||
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted, fetchMsChecklistItems } from '@/lib/microsoft-todo';
|
||||
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface SourceList {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -23,6 +21,7 @@ interface ImportedTask {
|
||||
status: string;
|
||||
sourceListTitle: string;
|
||||
parentExternalId?: string;
|
||||
important?: boolean;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@ -128,6 +127,7 @@ export async function POST(req: NextRequest) {
|
||||
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
|
||||
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
|
||||
sourceListTitle: sourceList.title,
|
||||
important: t.importance === 'high',
|
||||
});
|
||||
|
||||
// Fetch checklist items as sub-tasks
|
||||
@ -295,6 +295,7 @@ export async function POST(req: NextRequest) {
|
||||
somedayListId: somedayList.id,
|
||||
lastSyncedAt: new Date(),
|
||||
deletedAt: null, // clear soft-delete from a previous disconnect
|
||||
...(task.important !== undefined ? { importance: task.important ? true : existingTask.importance } : {}),
|
||||
}
|
||||
});
|
||||
externalToLocalId.set(task.externalId, existingTask.id);
|
||||
@ -313,7 +314,8 @@ export async function POST(req: NextRequest) {
|
||||
externalId: task.externalId,
|
||||
externalProvider: provider,
|
||||
externalListId: task.externalListId,
|
||||
lastSyncedAt: new Date()
|
||||
lastSyncedAt: new Date(),
|
||||
...(task.important ? { importance: true } : {}),
|
||||
}
|
||||
});
|
||||
externalToLocalId.set(task.externalId, newTask.id);
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
||||
import { fetchMsTodoLists } from '@/lib/microsoft-todo';
|
||||
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient, Task } from '@prisma/client';
|
||||
import { Task } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { notifyUser } from '@/lib/sse';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Validate and sanitize a URL — only allow http/https, reject javascript: and data: schemes
|
||||
function sanitizeUrl(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
@ -25,6 +24,71 @@ const generateVirtualId = (originalId: string, dateStr: string) => {
|
||||
return `virtual-${originalId}-${dateStr}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Push a single task field-update to its external provider (Outlook / Google / Synology).
|
||||
* Best-effort: fire-and-forget from the caller, which logs errors.
|
||||
*/
|
||||
async function pushTaskToExternal(
|
||||
task: Task,
|
||||
fields: { title?: string; notes?: string; completed?: boolean; scheduledDate?: string | null; importance?: boolean | null }
|
||||
): Promise<void> {
|
||||
if (!task.externalProvider || !task.externalId || !task.externalListId) return;
|
||||
|
||||
if (task.externalProvider === 'outlook') {
|
||||
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
|
||||
const { updateMsTodoTask } = await import('@/lib/microsoft-todo');
|
||||
const token = await getOutlookAccessToken(task.userId);
|
||||
if (!token) return;
|
||||
const updates: any = {};
|
||||
if (fields.title !== undefined) updates.title = fields.title;
|
||||
if (fields.notes !== undefined) updates.body = fields.notes ?? '';
|
||||
if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'notStarted';
|
||||
if (fields.scheduledDate !== undefined) {
|
||||
updates.dueDateTime = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
|
||||
}
|
||||
if (fields.importance !== undefined) updates.importance = fields.importance ? 'high' : 'normal';
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateMsTodoTask(token, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
} else if (task.externalProvider === 'google') {
|
||||
const { createGoogleClient, updateGoogleTask } = await import('@/lib/google-tasks');
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId: task.userId, provider: { in: ['google-calendar', 'google'] } }
|
||||
});
|
||||
if (!account?.access_token) return;
|
||||
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||
const updates: any = {};
|
||||
if (fields.title !== undefined) updates.title = fields.title;
|
||||
if (fields.notes !== undefined) updates.notes = fields.notes ?? '';
|
||||
if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'needsAction';
|
||||
if (fields.scheduledDate !== undefined) {
|
||||
updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
|
||||
}
|
||||
// Google Tasks API has no native importance/star — silently ignored.
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateGoogleTask(client, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
} else if (task.externalProvider === 'synology') {
|
||||
const { updateSynologyTask } = await import('@/lib/synology-tasks');
|
||||
const conn = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: task.userId, provider: 'synology' }
|
||||
});
|
||||
if (!conn?.accessToken || !conn?.refreshToken) return;
|
||||
const [user, pw] = conn.accessToken.split(':');
|
||||
if (!user || !pw) return;
|
||||
const updates: any = {};
|
||||
if (fields.title !== undefined) updates.title = fields.title;
|
||||
if (fields.notes !== undefined) updates.notes = fields.notes ?? '';
|
||||
if (fields.completed !== undefined) updates.completed = fields.completed;
|
||||
if (fields.scheduledDate !== undefined) {
|
||||
updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateSynologyTask(conn.refreshToken, user, pw, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max virtual instances generated per recurring series, keyed by recurrence unit.
|
||||
// Caps pathological cases (e.g. a daily task with 90-day horizon = 90 instances).
|
||||
const MAX_INSTANCES_PER_SERIES: Record<string, number> = {
|
||||
@ -309,7 +373,12 @@ export async function POST(request: NextRequest) {
|
||||
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
|
||||
const accessToken = await getOutlookAccessToken(userId);
|
||||
if (accessToken) {
|
||||
const msTask = await createMsTodoTask(accessToken, somedayList.externalId, { title });
|
||||
const msTask = await createMsTodoTask(accessToken, somedayList.externalId, {
|
||||
title,
|
||||
body: description || undefined,
|
||||
dueDateTime: scheduledDate || undefined,
|
||||
importance: importance ? 'high' : undefined,
|
||||
});
|
||||
externalId = msTask.id;
|
||||
externalProvider = 'outlook';
|
||||
externalListId = somedayList.externalId;
|
||||
@ -510,6 +579,21 @@ export async function PATCH(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Push changes to external provider (fire-and-forget) when this task is linked
|
||||
if (task.externalProvider && task.externalId && task.externalListId) {
|
||||
const pushFields: Record<string, any> = {};
|
||||
if (title !== undefined) pushFields.title = title;
|
||||
if (description !== undefined) pushFields.notes = description;
|
||||
if (completed !== undefined) pushFields.completed = completed;
|
||||
if (scheduledDate !== undefined) pushFields.scheduledDate = scheduledDate;
|
||||
if (importance !== undefined) pushFields.importance = importance;
|
||||
if (Object.keys(pushFields).length > 0) {
|
||||
pushTaskToExternal(task, pushFields).catch(e =>
|
||||
console.error('[TASK-SYNC] external push failed:', e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: We REMOVED the "create next task on completion" logic block here.
|
||||
// Why? Because the projection system handles "next tasks" automatically.
|
||||
// If we kept it, completing a task would create a duplicate materialized task for the next date,
|
||||
|
||||
@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createGoogleClient, createGoogleTask, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks';
|
||||
import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
|
||||
import { fetchMsTodoTasksForSync, fetchMsTodoTasksDelta, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
|
||||
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||
|
||||
// GET - Pull changes from Google Tasks into local DB
|
||||
@ -144,11 +144,8 @@ export async function GET(req: NextRequest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteUpdated = new Date(remote.updated);
|
||||
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
|
||||
if (remoteUpdated <= localUpdated) continue;
|
||||
|
||||
const updateData: any = { lastSyncedAt: new Date() };
|
||||
// Field-by-field reconciliation (see Outlook block for rationale).
|
||||
const updateData: any = {};
|
||||
|
||||
const remoteCompleted = remote.status === 'completed';
|
||||
if (remoteCompleted !== localTask.completed) {
|
||||
@ -173,17 +170,13 @@ export async function GET(req: NextRequest) {
|
||||
updateData.parentTaskId = null;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 1) {
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
updateData.lastSyncedAt = new Date();
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: updateData
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: { lastSyncedAt: new Date() }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,31 +268,110 @@ export async function GET(req: NextRequest) {
|
||||
outlookByList.get(task.externalListId)!.push(task);
|
||||
}
|
||||
|
||||
// Pre-load delta tokens for synced lists so we can do incremental pulls
|
||||
const syncedListIdToRow = new Map<string, { id: string; title: string; syncDeltaToken: string | null }>();
|
||||
for (const sl of outlookSyncedLists) {
|
||||
if (sl.externalId) {
|
||||
syncedListIdToRow.set(sl.externalId, {
|
||||
id: sl.id,
|
||||
title: sl.title,
|
||||
syncDeltaToken: (sl as any).syncDeltaToken ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const listId of outlookListIds) {
|
||||
const localTasks = outlookByList.get(listId) || [];
|
||||
try {
|
||||
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
|
||||
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
|
||||
const syncedListRow = syncedListIdToRow.get(listId);
|
||||
const previousDeltaToken = syncedListRow?.syncDeltaToken ?? null;
|
||||
|
||||
// Try delta-first when we have a stored token and a linked someday list
|
||||
// (delta only makes sense when we can also create local tasks for new
|
||||
// remote ones — otherwise we risk losing creations on a stale chain).
|
||||
let remoteTasks: any[];
|
||||
let isDelta = false;
|
||||
let newDeltaToken: string | null = null;
|
||||
|
||||
if (syncedListRow && previousDeltaToken) {
|
||||
try {
|
||||
const result = await fetchMsTodoTasksDelta(outlookToken, listId, previousDeltaToken);
|
||||
remoteTasks = result.tasks;
|
||||
newDeltaToken = result.deltaToken;
|
||||
isDelta = true;
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'DELTA_EXPIRED') {
|
||||
console.log(`[SYNC] Delta token expired for ${listId}, falling back to full fetch`);
|
||||
remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
|
||||
// Establish a fresh delta chain on the next sync
|
||||
const fresh = await fetchMsTodoTasksDelta(outlookToken, listId, null).catch(() => null);
|
||||
newDeltaToken = fresh?.deltaToken ?? null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else if (syncedListRow) {
|
||||
// First time we sync this list — do a full fetch AND prime the delta chain
|
||||
remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
|
||||
const fresh = await fetchMsTodoTasksDelta(outlookToken, listId, null).catch(() => null);
|
||||
newDeltaToken = fresh?.deltaToken ?? null;
|
||||
} else {
|
||||
// Not a synced list — just touching individual tasks; full fetch is fine
|
||||
remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
|
||||
}
|
||||
|
||||
const remoteMap = new Map(remoteTasks.map((t: any) => [t.id, t]));
|
||||
const existingExternalIds = new Set(localTasks.map(t => t.externalId));
|
||||
|
||||
// -- Delta deletions: remote tasks marked _deleted are gone in Outlook --
|
||||
if (isDelta) {
|
||||
const deletedIds = remoteTasks.filter((r: any) => r._deleted).map((r: any) => r.id);
|
||||
if (deletedIds.length > 0) {
|
||||
const result = await prisma.task.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
externalProvider: 'outlook',
|
||||
externalListId: listId,
|
||||
externalId: { in: deletedIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
deleted += result.count;
|
||||
}
|
||||
}
|
||||
|
||||
// For full fetches, detect locally-known tasks that vanished from Outlook
|
||||
if (!isDelta) {
|
||||
for (const localTask of localTasks) {
|
||||
const remote = remoteMap.get(localTask.externalId!);
|
||||
|
||||
if (!remote) {
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: { deletedAt: new Date() }
|
||||
});
|
||||
deleted++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remoteUpdated = new Date(remote.lastModifiedDateTime);
|
||||
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
|
||||
if (remoteUpdated <= localUpdated) continue;
|
||||
// -- Reconcile updates --
|
||||
// For delta: iterate the (small) returned list and reconcile each.
|
||||
// For full: iterate local tasks (we already handled deletions above).
|
||||
const localById = new Map(localTasks.map(t => [t.externalId!, t]));
|
||||
const reconcileTargets = isDelta
|
||||
? remoteTasks.filter((r: any) => !r._deleted)
|
||||
: remoteTasks;
|
||||
|
||||
const updateData: any = { lastSyncedAt: new Date() };
|
||||
for (const remote of reconcileTargets) {
|
||||
const localTask = localById.get(remote.id);
|
||||
if (!localTask) continue; // new task — handled below
|
||||
|
||||
// Field-by-field reconciliation: always check each field and update if it
|
||||
// differs from remote. Local-side mutations are pushed eagerly via
|
||||
// /api/tasks PATCH, so a remote-side change is the authoritative source
|
||||
// when fields disagree at pull time.
|
||||
const updateData: any = {};
|
||||
|
||||
const remoteCompleted = isMsTodoTaskCompleted(remote.status);
|
||||
if (remoteCompleted !== localTask.completed) {
|
||||
@ -315,24 +387,36 @@ export async function GET(req: NextRequest) {
|
||||
updateData.description = remoteNotes;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 1) {
|
||||
// Outlook To-Do star ⇄ local importance flag
|
||||
const remoteImportant = remote.importance === 'high';
|
||||
if (remoteImportant !== (localTask.importance === true)) {
|
||||
updateData.importance = remoteImportant;
|
||||
}
|
||||
|
||||
// Outlook dueDateTime ⇄ local scheduledDate
|
||||
const remoteDue = remote.dueDateTime?.dateTime
|
||||
? new Date(remote.dueDateTime.dateTime)
|
||||
: null;
|
||||
const localDue = localTask.scheduledDate ? new Date(localTask.scheduledDate) : null;
|
||||
const remoteDueMs = remoteDue?.getTime() ?? null;
|
||||
const localDueMs = localDue?.getTime() ?? null;
|
||||
if (remoteDueMs !== localDueMs) {
|
||||
updateData.scheduledDate = remoteDue;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
updateData.lastSyncedAt = new Date();
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: updateData
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: { lastSyncedAt: new Date() }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create new local tasks for remote tasks not yet in local DB
|
||||
const somedayListInfo = outlookListIdToSomedayList.get(listId);
|
||||
if (somedayListInfo) {
|
||||
const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id));
|
||||
// -- Create new local tasks for remote tasks we don't have yet --
|
||||
if (syncedListRow) {
|
||||
const newRemoteTasks = reconcileTargets.filter((rt: any) => !existingExternalIds.has(rt.id));
|
||||
|
||||
for (const remote of newRemoteTasks) {
|
||||
if (!remote.title || !remote.title.trim()) continue;
|
||||
@ -343,7 +427,11 @@ export async function GET(req: NextRequest) {
|
||||
title: remote.title,
|
||||
description: remote.body?.content || null,
|
||||
completed: isMsTodoTaskCompleted(remote.status),
|
||||
somedayListId: somedayListInfo.id,
|
||||
importance: remote.importance === 'high' ? true : null,
|
||||
scheduledDate: remote.dueDateTime?.dateTime
|
||||
? new Date(remote.dueDateTime.dateTime)
|
||||
: null,
|
||||
somedayListId: syncedListRow.id,
|
||||
externalId: remote.id,
|
||||
externalProvider: 'outlook',
|
||||
externalListId: listId,
|
||||
@ -354,6 +442,14 @@ export async function GET(req: NextRequest) {
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the new delta token for next call (only when we got one)
|
||||
if (syncedListRow && newDeltaToken) {
|
||||
await prisma.somedayList.update({
|
||||
where: { id: syncedListRow.id },
|
||||
data: { syncDeltaToken: newDeltaToken },
|
||||
});
|
||||
}
|
||||
} catch (listError) {
|
||||
console.error(`Error syncing Outlook list ${listId}:`, listError);
|
||||
}
|
||||
@ -484,7 +580,7 @@ export async function PATCH(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { taskId, completed, title, action, scheduledDate, notes } = body;
|
||||
const { taskId, completed, title, action, scheduledDate, notes, importance } = body;
|
||||
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ error: 'Task ID required' }, { status: 400 });
|
||||
@ -538,13 +634,16 @@ export async function PATCH(req: NextRequest) {
|
||||
if (action === 'delete') {
|
||||
await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId);
|
||||
} else {
|
||||
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null } = {};
|
||||
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null; importance?: 'low' | 'normal' | 'high' } = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (notes !== undefined) updates.body = notes;
|
||||
if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted';
|
||||
if (scheduledDate !== undefined) {
|
||||
updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null;
|
||||
}
|
||||
if (importance !== undefined) {
|
||||
updates.importance = importance ? 'high' : 'normal';
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
@ -658,6 +757,7 @@ export async function POST(req: NextRequest) {
|
||||
title: task.title,
|
||||
body: task.description || undefined,
|
||||
dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
|
||||
importance: task.importance ? 'high' : undefined,
|
||||
});
|
||||
|
||||
const updatedTask = await prisma.task.update({
|
||||
|
||||
@ -114,6 +114,22 @@ function dayDateLabel(d: Date, de: boolean): string {
|
||||
});
|
||||
}
|
||||
|
||||
// Strip Apple Calendar icon encodings (=h=g, =i=g, etc.) and emoji from
|
||||
// event titles before PDF rendering. Helvetica cannot render these glyphs and
|
||||
// they appear as raw escape sequences. The colored dot/chip already identifies
|
||||
// the calendar visually, so the icon character carries no extra information.
|
||||
function pdfTitle(s: string | null | undefined, maxLen = 60): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/(?:=[a-zA-Z])+/g, '') // Apple CalDAV icon escapes: =h=g, =i=g, =k=g …
|
||||
.replace(/[\u{1F000}-\u{1FFFF}]/gu, '') // emoji (supplementary plane)
|
||||
.replace(/[\u{2600}-\u{27BF}]/gu, '') // misc symbols & dingbats
|
||||
.replace(/[\uE000-\uF8FF]/g, '') // Apple private-use area
|
||||
.replace(/[\uFE00-\uFE0F\u200D]/g, '') // variation selectors / ZWJ
|
||||
.trim()
|
||||
.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function fmtHour(h: number): string {
|
||||
return `${String(h).padStart(2, '0')}:00`;
|
||||
}
|
||||
@ -454,7 +470,7 @@ function WeekCalendarPDF({
|
||||
return React.createElement(View, {
|
||||
key: t.id,
|
||||
style: { borderRadius: 2, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, backgroundColor: DONE_BG },
|
||||
}, React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT } }, (t.title || '').slice(0, 36)));
|
||||
}, React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT } }, pdfTitle(t.title, 36)));
|
||||
}
|
||||
if (t.isExternal) {
|
||||
// Calendar event: solid color chip with white text + dot indicator (matches webapp style)
|
||||
@ -473,7 +489,7 @@ function WeekCalendarPDF({
|
||||
React.createElement(View, {
|
||||
style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: evTextColor, opacity: 0.7, marginRight: 3, flexShrink: 0 },
|
||||
}),
|
||||
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: evTextColor, flex: 1 } }, (t.title || '').slice(0, 36)),
|
||||
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: evTextColor, flex: 1 } }, pdfTitle(t.title, 36)),
|
||||
);
|
||||
}
|
||||
// User-created all-day task: left border only, no background fill
|
||||
@ -481,7 +497,7 @@ function WeekCalendarPDF({
|
||||
return React.createElement(View, {
|
||||
key: t.id,
|
||||
style: { borderLeftWidth: 2, borderLeftColor: accent, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2 },
|
||||
}, React.createElement(Text, { style: { fontSize: 7.5, color: '#374151' } }, (t.title || '').slice(0, 36)));
|
||||
}, React.createElement(Text, { style: { fontSize: 7.5, color: '#374151' } }, pdfTitle(t.title, 36)));
|
||||
}),
|
||||
allDayTasks.length > 5 ? React.createElement(Text, {
|
||||
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
|
||||
@ -575,7 +591,7 @@ function WeekCalendarPDF({
|
||||
key: t.id,
|
||||
style: { marginBottom: 1, paddingLeft: 1, paddingVertical: 1 },
|
||||
},
|
||||
React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT, lineHeight: 1.3, textDecoration: 'line-through' } }, (t.title || '').slice(0, 60)),
|
||||
React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT, lineHeight: 1.3, textDecoration: 'line-through' } }, pdfTitle(t.title)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -589,7 +605,7 @@ function WeekCalendarPDF({
|
||||
paddingLeft: 3, paddingVertical: 1, marginBottom: 1,
|
||||
},
|
||||
},
|
||||
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
|
||||
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, pdfTitle(t.title)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -601,7 +617,7 @@ function WeekCalendarPDF({
|
||||
paddingVertical: 1, marginBottom: 1,
|
||||
},
|
||||
},
|
||||
React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
|
||||
React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, pdfTitle(t.title)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@ -719,7 +735,7 @@ function WeekCalendarPDF({
|
||||
}),
|
||||
React.createElement(Text, {
|
||||
style: { fontSize: 7, color: '#374151', lineHeight: 1.3, flex: 1 },
|
||||
}, (t.title || '').slice(0, 52)),
|
||||
}, pdfTitle(t.title)),
|
||||
)
|
||||
),
|
||||
list.tasks.length > 18 ? React.createElement(Text, {
|
||||
|
||||
@ -111,6 +111,8 @@ export async function GET(request: NextRequest) {
|
||||
weatherLocation: true,
|
||||
weatherRecentCities: true,
|
||||
viewSettings: true,
|
||||
customCssVars: true,
|
||||
customCss: true,
|
||||
hasCompletedOnboarding: true,
|
||||
createdAt: true
|
||||
}
|
||||
@ -156,6 +158,7 @@ export async function PATCH(request: NextRequest) {
|
||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages,
|
||||
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
|
||||
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
|
||||
customCssVars, customCss,
|
||||
hasCompletedOnboarding
|
||||
} = body;
|
||||
|
||||
@ -256,6 +259,8 @@ export async function PATCH(request: NextRequest) {
|
||||
...(weatherLocation !== undefined && { weatherLocation }),
|
||||
...(weatherRecentCities !== undefined && { weatherRecentCities }),
|
||||
...(viewSettings !== undefined && { viewSettings }),
|
||||
...(customCssVars !== undefined && { customCssVars }),
|
||||
...(customCss !== undefined && { customCss }),
|
||||
...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }),
|
||||
};
|
||||
if (password && password.trim() !== "") {
|
||||
@ -364,6 +369,8 @@ export async function PATCH(request: NextRequest) {
|
||||
weatherLocation: true,
|
||||
weatherRecentCities: true,
|
||||
viewSettings: true,
|
||||
customCssVars: true,
|
||||
customCss: true,
|
||||
hasCompletedOnboarding: true,
|
||||
}
|
||||
});
|
||||
|
||||
@ -34,10 +34,11 @@ export default function SignupPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const language = (typeof navigator !== 'undefined' ? navigator.language : 'en').slice(0, 2).toLowerCase();
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
body: JSON.stringify({ name, email, password, language }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@ -7,13 +7,16 @@ import Link from 'next/link';
|
||||
function VerifyEmailContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get('email') || '';
|
||||
const initialEmail = searchParams.get('email') || '';
|
||||
const tokenFromUrl = searchParams.get('token') || '';
|
||||
const error = searchParams.get('error');
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [isTokenVerifying, setIsTokenVerifying] = useState(false);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -26,10 +29,38 @@ function VerifyEmailContent() {
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Auto-focus first input
|
||||
// Auto-focus first input (only if we're not in token-link mode)
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
if (!tokenFromUrl) inputRefs.current[0]?.focus();
|
||||
}, [tokenFromUrl]);
|
||||
|
||||
// Token-from-link flow: user landed here from the email button; we wait
|
||||
// for them to click "Verify Email" rather than firing automatically, so
|
||||
// inbox scanners that pre-fetch the URL never consume the token.
|
||||
const verifyByToken = async () => {
|
||||
setIsTokenVerifying(true);
|
||||
setErrorMsg(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-email', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: tokenFromUrl }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.email && !email) setEmail(data.email);
|
||||
if (!response.ok) {
|
||||
setErrorMsg(data.error || 'Verification failed');
|
||||
setIsTokenVerifying(false);
|
||||
return;
|
||||
}
|
||||
setMessage('Email verified! Redirecting to login...');
|
||||
setTimeout(() => router.push('/auth/login?verified=true'), 1500);
|
||||
} catch {
|
||||
setErrorMsg('Something went wrong. Please try again.');
|
||||
setIsTokenVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (index: number, value: string) => {
|
||||
// Handle paste of full code
|
||||
@ -140,9 +171,15 @@ function VerifyEmailContent() {
|
||||
<h2 style={{ color: '#333', fontSize: '1.25rem', fontWeight: 600, textAlign: 'center', margin: '0 0 8px' }}>
|
||||
Verify your email
|
||||
</h2>
|
||||
{tokenFromUrl ? (
|
||||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
||||
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email}</strong>
|
||||
Click the button below to confirm{email ? <> <strong style={{ color: '#667eea' }}>{email}</strong></> : ' your email'}.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
||||
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email || 'your email'}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{errorMsg && (
|
||||
@ -165,6 +202,38 @@ function VerifyEmailContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email recovery field when we don't know it (e.g. landed via expired-link redirect).
|
||||
Without this, the resend button has nothing to send to — that was the actual UX
|
||||
gap behind point #1. */}
|
||||
{!email && (
|
||||
<div className="weekly-auth-field" style={{ marginBottom: '16px' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email address"
|
||||
autoComplete="email"
|
||||
className="weekly-auth-input"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token-link flow: prominent verify button */}
|
||||
{tokenFromUrl && !message && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={verifyByToken}
|
||||
disabled={isTokenVerifying}
|
||||
className="weekly-auth-button primary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isTokenVerifying ? 'Verifying...' : 'Verify Email'}
|
||||
</button>
|
||||
<p style={{ color: '#888', fontSize: '0.8rem', textAlign: 'center', margin: '12px 0 0' }}>
|
||||
Or enter the 6-digit code instead:
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code Input */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@ -32,6 +32,7 @@ body {
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.005em;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
@ -474,6 +475,46 @@ h3 {
|
||||
--weekly-settings-toggle-active-text: #111827;
|
||||
--weekly-header-brightness: 1;
|
||||
--weekly-goal-brightness: 1;
|
||||
|
||||
/* ── v2 design token aliases ── */
|
||||
/* Map to existing weekly vars where a match exists */
|
||||
--bg: var(--weekly-bg);
|
||||
--paper: var(--weekly-bg);
|
||||
--ink: var(--weekly-text);
|
||||
--ink-2: #2a2a28;
|
||||
--ink-3: var(--weekly-text-light);
|
||||
--ink-4: #a8a8a2;
|
||||
--ink-5: #c8c8c2;
|
||||
--accent: var(--weekly-teal);
|
||||
--accent-soft: rgba(0, 154, 154, 0.08);
|
||||
--line: var(--weekly-border);
|
||||
--line-soft: #f3f3f0;
|
||||
--weekend: #dc2626;
|
||||
--tasks-bg: #f4f2ee;
|
||||
|
||||
/* Calendar-source event palette */
|
||||
--ev-default-bg: #eef2f7;
|
||||
--ev-default-border: #6c87a8;
|
||||
--ev-default-title: #2b3f57;
|
||||
--ev-default-meta: #6c87a8;
|
||||
--ev-fam-bg: #fbeef0;
|
||||
--ev-fam-border: #b85a6a;
|
||||
--ev-fam-title: #5a2530;
|
||||
--ev-fam-meta: #99536a;
|
||||
--ev-fin-bg: #f6f0e2;
|
||||
--ev-fin-border: #a88a3c;
|
||||
--ev-fin-title: #4a3a14;
|
||||
--ev-fin-meta: #8a6f2a;
|
||||
--ev-dev-bg: #ecf3ed;
|
||||
--ev-dev-border: #5a7a4a;
|
||||
--ev-dev-title: #2c4527;
|
||||
--ev-dev-meta: #5a7a4a;
|
||||
|
||||
/* All-day chip palette */
|
||||
--chip-default-bg: #b8b8b0;
|
||||
--chip-fam-bg: #e88a8a;
|
||||
--chip-special-bg: #f4d588;
|
||||
--chip-special-text: #6b4f10;
|
||||
}
|
||||
|
||||
/* Weekly Main Container */
|
||||
@ -492,7 +533,7 @@ h3 {
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--weekly-bg);
|
||||
border-bottom: 1px solid var(--weekly-border);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -1237,7 +1278,7 @@ h3 {
|
||||
.weekly-someday {
|
||||
background-color: #f9f9f9;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -1319,14 +1360,15 @@ h3 {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
gap: 1.5rem;
|
||||
gap: 12px;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding-bottom: 1rem;
|
||||
padding: 12px 12px 16px;
|
||||
align-items: flex-start;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
overflow: visible; /* Let parent container handle scrolling */
|
||||
overflow: visible;
|
||||
background-color: #F9F9F9;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling for Someday Container */
|
||||
@ -1372,37 +1414,45 @@ h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ruled paper lines for someday tasks */
|
||||
/* Someday list cards */
|
||||
.weekly-someday-list {
|
||||
padding: 0;
|
||||
min-height: 200px;
|
||||
flex: 0 0 300px; /* Slightly wider lists */
|
||||
flex: 0 0 300px;
|
||||
width: 300px;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: visible;
|
||||
scrollbar-width: none; /* Hide scrollbar Firefox */
|
||||
-ms-overflow-style: none; /* Hide scrollbar IE/Edge */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
background: var(--weekly-bg, #fff);
|
||||
border: 1px solid var(--weekly-border, #e5e7eb);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekly-someday-list::-webkit-scrollbar {
|
||||
display: none; /* Hide scrollbar Chrome/Safari/Webkit */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-someday-list {
|
||||
background-image: none;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.task-list-slot {
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--weekly-border);
|
||||
box-sizing: border-box;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.task-list-slot.drop-target {
|
||||
@ -1463,7 +1513,7 @@ h3 {
|
||||
}
|
||||
|
||||
.weekly-someday-list:last-child {
|
||||
border-right: none;
|
||||
/* gap handles spacing, no extra right border needed */
|
||||
}
|
||||
|
||||
.weekly-someday-list.is-dragging {
|
||||
@ -1473,9 +1523,9 @@ h3 {
|
||||
|
||||
.weekly-someday-list .weekly-task-item {
|
||||
border-bottom: none;
|
||||
min-height: 38px;
|
||||
min-height: 24px;
|
||||
height: auto;
|
||||
padding: 6px 1rem;
|
||||
padding: 4px 4px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: visible !important;
|
||||
@ -1495,36 +1545,38 @@ h3 {
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
padding: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.weekly-someday-list-title-header {
|
||||
padding: 0 1rem;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 0;
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--weekly-border);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .weekly-someday-list-title-header {
|
||||
border-bottom-color: var(--weekly-border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.weekly-someday-list-title-input {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--weekly-text, #222);
|
||||
background: transparent;
|
||||
border: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
transition: opacity 0.15s ease;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.weekly-someday .weekly-task-item {
|
||||
@ -1542,29 +1594,29 @@ h3 {
|
||||
.someday-add-task-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #aaa;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 4px 0;
|
||||
font-size: 0.8rem;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.someday-add-task-btn:hover {
|
||||
color: #666;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Someday Tabs Bar (horizontal above grid) */
|
||||
.someday-tabs-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--weekly-border);
|
||||
padding: 0px 12px 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
|
||||
.someday-tabs-bar::-webkit-scrollbar {
|
||||
@ -1576,13 +1628,14 @@ h3 {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--weekly-text-light, #999);
|
||||
padding: 3px;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
padding:8px;
|
||||
}
|
||||
|
||||
.someday-bar-icon-btn:hover {
|
||||
@ -1592,60 +1645,69 @@ h3 {
|
||||
|
||||
.someday-tabs-bar-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
height: 34px;
|
||||
background: var(--weekly-border, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
margin: 0 2px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Someday Tab Buttons (horizontal) */
|
||||
|
||||
/* Someday Tab Buttons (horizontal) — pill + underline style */
|
||||
.someday-tab-btn-h {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--weekly-text-light, #999);
|
||||
font-size: 0.75rem;
|
||||
color: var(--weekly-text-light, #888);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
padding: 5px 12px 4px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.someday-tab-btn-h:hover {
|
||||
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
|
||||
color: var(--weekly-text, #333);
|
||||
border-color: var(--weekly-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.someday-tab-btn-h.active {
|
||||
background: var(--weekly-accent, #6366f1);
|
||||
color: #fff;
|
||||
background: #f9f9f9;
|
||||
color: var(--weekly-text, #222);
|
||||
font-weight: 700;
|
||||
box-shadow: 2px 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.weekly-container.dark-mode .someday-tab-btn-h.active {
|
||||
background: #1a1a1b;
|
||||
border-bottom-color: #1a1a1b;
|
||||
}
|
||||
|
||||
.someday-tab-btn-h.drag-over {
|
||||
outline: 2px dashed var(--weekly-accent, #6366f1);
|
||||
outline-offset: -1px;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.someday-tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.08));
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
color: var(--weekly-text-light, #888);
|
||||
}
|
||||
|
||||
.someday-tab-btn-h.active .someday-tab-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--weekly-accent, #000);
|
||||
}
|
||||
|
||||
/* Someday Tab Wrapper with dissolve button */
|
||||
@ -1658,7 +1720,7 @@ h3 {
|
||||
.someday-tab-dissolve-h {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: -4px;
|
||||
top: -6px;
|
||||
background: var(--weekly-bg, #fff);
|
||||
border: 1px solid var(--weekly-border, #e5e7eb);
|
||||
border-radius: 50%;
|
||||
@ -2893,7 +2955,7 @@ h3 {
|
||||
}
|
||||
|
||||
.time-slot-label span {
|
||||
transform: translateY(-50%); /* Centers the label on the grid line */
|
||||
transform: translateY(0%); /* Centers the label on the grid line */
|
||||
background: var(--weekly-bg);
|
||||
padding: 0 4px;
|
||||
z-index: 1;
|
||||
@ -3208,26 +3270,29 @@ h3 {
|
||||
|
||||
/* Resize handle for draggable section borders */
|
||||
.resize-handle {
|
||||
height: 7px;
|
||||
height: 2px;
|
||||
border: 1px solid #ccc;
|
||||
cursor: ns-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--weekly-border, #e5e7eb);
|
||||
border-bottom: 1px solid var(--weekly-border, #e5e7eb);
|
||||
overflow: visible;
|
||||
background-color: #ddd;
|
||||
}
|
||||
.resize-handle:hover {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: var(--weekly-accent, #3b82f6);
|
||||
background-color: #d7d7d7;
|
||||
border-color: #b8b8b8;
|
||||
}
|
||||
.resize-handle:active {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: var(--weekly-accent, #3b82f6);
|
||||
background-color: #d1d1d1;
|
||||
border-color: #aaa;
|
||||
}
|
||||
.resize-handle-bar {
|
||||
width: 32px;
|
||||
@ -3237,10 +3302,50 @@ h3 {
|
||||
opacity: 0.6;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.resize-handle-anyday {
|
||||
height: 2px;
|
||||
}
|
||||
.resize-handle-allday {
|
||||
height: 2px;
|
||||
}
|
||||
.resize-handle-anyday .resize-handle-bar {
|
||||
width: 23px;
|
||||
height: 9px;
|
||||
border-radius: 4px;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--weekly-text, #333) 48%, transparent) 1.25px, transparent 1.35px) 2px 1px / 6px 5px repeat;
|
||||
background-color: #ddd;
|
||||
border: 1px solid #ccc;
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
overflow: visible;
|
||||
z-index: 50;
|
||||
}
|
||||
.resize-handle-allday .resize-handle-bar {
|
||||
width: 23px;
|
||||
height: 9px;
|
||||
border-radius: 4px;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--weekly-text, #333) 48%, transparent) 1.25px, transparent 1.35px) 2px 1px / 6px 5px repeat;
|
||||
background-color: #ddd;
|
||||
border: 1px solid #ccc;
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
overflow: visible;
|
||||
z-index: 50;
|
||||
}
|
||||
.resize-handle:hover .resize-handle-bar,
|
||||
.resize-handle:active .resize-handle-bar {
|
||||
background: var(--weekly-accent, #3b82f6);
|
||||
opacity: 0.5;
|
||||
opacity: 1;
|
||||
}
|
||||
.resize-handle-anyday:hover .resize-handle-bar,
|
||||
.resize-handle-anyday:active .resize-handle-bar,
|
||||
.resize-handle-allday:hover .resize-handle-bar,
|
||||
.resize-handle-allday:active .resize-handle-bar {
|
||||
background: radial-gradient(circle, var(--weekly-accent, #3b82f6) 1.25px, transparent 1.35px) 2px 1.5px / 6px 5px repeat;
|
||||
background-color: #d7d7d7;
|
||||
border-color: #b8b8b8;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.all-day-events-header {
|
||||
@ -4729,6 +4834,42 @@ h3 {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* --- Mobile Left Sidebar Rail --- */
|
||||
.mobile-left-sidebar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mobile-left-sidebar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-rail-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--weekly-text-light, #888);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mobile-rail-btn:hover,
|
||||
.mobile-rail-btn:active {
|
||||
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.07));
|
||||
color: var(--weekly-text, #333);
|
||||
}
|
||||
.dark-mode .mobile-rail-btn:hover,
|
||||
.dark-mode .mobile-rail-btn:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.mobile-rail-backdrop {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* --- Mobile Quick Actions in Sidebar --- */
|
||||
.mobile-quick-nav-btn {
|
||||
min-width: 44px;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
|
||||
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity } from 'lucide-react';
|
||||
import React, { useState, useEffect, lazy, Suspense, useRef, useCallback } from 'react';
|
||||
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity, Check } from 'lucide-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
||||
@ -27,7 +27,7 @@ function AnimatedDots() {
|
||||
}
|
||||
|
||||
// Reminder preset options (minutes)
|
||||
const REMINDER_OPTIONS = [
|
||||
const REMINDER_PRESETS = [
|
||||
{ label: 'None', value: -1 },
|
||||
{ label: 'At time of event', value: 0 },
|
||||
{ label: '5 minutes before', value: 5 },
|
||||
@ -40,6 +40,25 @@ const REMINDER_OPTIONS = [
|
||||
{ label: '1 week before', value: 10080 },
|
||||
];
|
||||
|
||||
const CUSTOM_REMINDER_SENTINEL = -9999;
|
||||
|
||||
function formatReminderMinutes(minutes: number, language: string): string {
|
||||
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
|
||||
if (minutes % 10080 === 0) {
|
||||
const w = minutes / 10080;
|
||||
return language === 'de' ? `${w} ${w === 1 ? 'Woche' : 'Wochen'} vorher` : `${w} week${w !== 1 ? 's' : ''} before`;
|
||||
}
|
||||
if (minutes % 1440 === 0) {
|
||||
const d = minutes / 1440;
|
||||
return language === 'de' ? `${d} ${d === 1 ? 'Tag' : 'Tage'} vorher` : `${d} day${d !== 1 ? 's' : ''} before`;
|
||||
}
|
||||
if (minutes % 60 === 0) {
|
||||
const h = minutes / 60;
|
||||
return language === 'de' ? `${h} ${h === 1 ? 'Stunde' : 'Stunden'} vorher` : `${h} hour${h !== 1 ? 's' : ''} before`;
|
||||
}
|
||||
return language === 'de' ? `${minutes} Minuten vorher` : `${minutes} minutes before`;
|
||||
}
|
||||
|
||||
const BUSY_STATUS_OPTIONS = [
|
||||
{ label: 'Busy', value: 'busy' },
|
||||
{ label: 'Free', value: 'free' },
|
||||
@ -63,6 +82,10 @@ interface CalendarEventModalProps {
|
||||
connections: any[];
|
||||
weekStartDay?: number; // 0=Sunday, 1=Monday
|
||||
language?: string;
|
||||
savedLocations?: string[];
|
||||
onSaveLocation?: (loc: string) => void;
|
||||
customReminderMinutes?: number[];
|
||||
onSaveCustomReminder?: (minutes: number) => void;
|
||||
onClose: () => void;
|
||||
onSave: (eventData: any) => Promise<void>;
|
||||
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
|
||||
@ -76,6 +99,10 @@ export default function CalendarEventModal({
|
||||
connections,
|
||||
weekStartDay = 0,
|
||||
language = 'en',
|
||||
savedLocations = [],
|
||||
onSaveLocation,
|
||||
customReminderMinutes = [],
|
||||
onSaveCustomReminder,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete
|
||||
@ -167,6 +194,53 @@ export default function CalendarEventModal({
|
||||
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Location autocomplete
|
||||
const [locationFocused, setLocationFocused] = useState(false);
|
||||
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]);
|
||||
const locationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateLocationSuggestions = useCallback((val: string) => {
|
||||
if (!val.trim() || savedLocations.length === 0) {
|
||||
setLocationSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const lower = val.toLowerCase();
|
||||
const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val);
|
||||
setLocationSuggestions(matches.slice(0, 6));
|
||||
}, [savedLocations]);
|
||||
|
||||
// Custom reminder state
|
||||
// customReminderIdx tracks which reminder row is showing the custom editor
|
||||
const [customEditorIdx, setCustomEditorIdx] = useState<number | null>(null);
|
||||
const [customAmount, setCustomAmount] = useState(30);
|
||||
const [reminderUnit, setReminderUnit] = useState<'minutes' | 'hours' | 'days'>('minutes');
|
||||
|
||||
const customAmountToMinutes = () => {
|
||||
if (reminderUnit === 'hours') return customAmount * 60;
|
||||
if (reminderUnit === 'days') return customAmount * 1440;
|
||||
return customAmount;
|
||||
};
|
||||
|
||||
// Build full reminder options list: presets + saved custom + "Custom..."
|
||||
const buildReminderOptions = () => [
|
||||
...REMINDER_PRESETS,
|
||||
...customReminderMinutes
|
||||
.filter(m => !REMINDER_PRESETS.some(p => p.value === m))
|
||||
.map(m => ({ label: formatReminderMinutes(m, language), value: m })),
|
||||
{ label: language === 'de' ? 'Benutzerdefiniert…' : 'Custom…', value: CUSTOM_REMINDER_SENTINEL },
|
||||
];
|
||||
|
||||
// Close location suggestions on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (locationRef.current && !locationRef.current.contains(e.target as Node)) {
|
||||
setLocationSuggestions([]);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// Focus trap
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@ -234,7 +308,7 @@ export default function CalendarEventModal({
|
||||
end: { dateTime: endDate.toISOString() },
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
reminders: activeReminders.length > 0 ? activeReminders : undefined,
|
||||
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
|
||||
busyStatus: busyStatus,
|
||||
visibility: visibility !== 'default' ? visibility : undefined,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
@ -261,6 +335,11 @@ export default function CalendarEventModal({
|
||||
return;
|
||||
}
|
||||
|
||||
// Save location to user profile if non-empty and new
|
||||
if (location.trim() && onSaveLocation) {
|
||||
onSaveLocation(location.trim());
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@ -348,11 +427,29 @@ export default function CalendarEventModal({
|
||||
const updateReminder = (index: number, minutes: number) => {
|
||||
if (minutes === -1) {
|
||||
setReminders(reminders.filter((_, i) => i !== index));
|
||||
setCustomEditorIdx(null);
|
||||
return;
|
||||
}
|
||||
if (minutes === CUSTOM_REMINDER_SENTINEL) {
|
||||
setCustomEditorIdx(index);
|
||||
setCustomAmount(30);
|
||||
setReminderUnit('minutes');
|
||||
return;
|
||||
}
|
||||
const updated = [...reminders];
|
||||
updated[index] = { ...updated[index], minutes };
|
||||
setReminders(updated);
|
||||
setCustomEditorIdx(null);
|
||||
};
|
||||
|
||||
const confirmCustomReminder = (index: number) => {
|
||||
const mins = customAmountToMinutes();
|
||||
if (mins <= 0) return;
|
||||
const updated = [...reminders];
|
||||
updated[index] = { ...updated[index], minutes: mins };
|
||||
setReminders(updated);
|
||||
setCustomEditorIdx(null);
|
||||
if (onSaveCustomReminder) onSaveCustomReminder(mins);
|
||||
};
|
||||
|
||||
const addReminder = () => {
|
||||
@ -687,24 +784,61 @@ export default function CalendarEventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
<div style={iconRow}>
|
||||
{/* Location with autocomplete */}
|
||||
<div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
|
||||
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={e => setLocation(e.target.value)}
|
||||
onChange={e => { setLocation(e.target.value); updateLocationSuggestions(e.target.value); }}
|
||||
onFocus={() => { setLocationFocused(true); updateLocationSuggestions(location); }}
|
||||
onBlur={() => setTimeout(() => setLocationSuggestions([]), 150)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') setLocationSuggestions([]); }}
|
||||
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
|
||||
aria-label={language === 'de' ? 'Ort' : 'Location'}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={locationSuggestions.length > 0}
|
||||
style={{
|
||||
...fieldCol, padding: '2px 0', border: 'none',
|
||||
background: 'transparent', outline: 'none', fontSize: '0.8rem',
|
||||
color: 'var(--weekly-text)',
|
||||
}}
|
||||
/>
|
||||
{locationSuggestions.length > 0 && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={language === 'de' ? 'Ortsvorschläge' : 'Location suggestions'}
|
||||
style={{
|
||||
position: 'absolute', top: '100%', left: '26px', right: 0,
|
||||
zIndex: 200, background: 'var(--weekly-bg-popover, #fff)',
|
||||
border: '1px solid var(--weekly-border, #e5e7eb)',
|
||||
borderRadius: '8px', boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
overflow: 'hidden', marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{locationSuggestions.map((suggestion, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }}
|
||||
style={{
|
||||
padding: '6px 10px', fontSize: '0.78rem',
|
||||
cursor: 'pointer', color: 'var(--weekly-text)',
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--weekly-hover, rgba(0,0,0,0.05))')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert */}
|
||||
{/* Alert / Reminders */}
|
||||
<div style={iconRow}>
|
||||
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
|
||||
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
@ -714,24 +848,80 @@ export default function CalendarEventModal({
|
||||
</button>
|
||||
) : (
|
||||
reminders.map((reminder, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<select
|
||||
value={reminder.minutes}
|
||||
value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
|
||||
onChange={e => updateReminder(idx, parseInt(e.target.value))}
|
||||
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
|
||||
style={{ ...inlineSelect, flex: 1 }}
|
||||
>
|
||||
{REMINDER_OPTIONS.map(opt => (
|
||||
{buildReminderOptions().map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
|
||||
onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
|
||||
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Custom reminder editor row */}
|
||||
{customEditorIdx === idx && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
paddingLeft: '0', background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
||||
borderRadius: '6px', padding: '4px 6px',
|
||||
}}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={9999}
|
||||
value={customAmount}
|
||||
onChange={e => setCustomAmount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
aria-label={language === 'de' ? 'Erinnerungsmenge' : 'Reminder amount'}
|
||||
style={{
|
||||
width: '48px', textAlign: 'center', fontSize: '0.78rem',
|
||||
background: 'var(--weekly-bg, #fff)', borderRadius: '5px',
|
||||
padding: '2px 4px', border: '1px solid var(--weekly-border, #ddd)',
|
||||
outline: 'none', color: 'var(--weekly-text)',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={reminderUnit}
|
||||
onChange={e => setReminderUnit(e.target.value as any)}
|
||||
aria-label={language === 'de' ? 'Erinnerungseinheit' : 'Reminder unit'}
|
||||
style={{ ...inlineSelect, flex: 1, fontSize: '0.75rem' }}
|
||||
>
|
||||
<option value="minutes">{language === 'de' ? 'Minuten' : 'minutes'}</option>
|
||||
<option value="hours">{language === 'de' ? 'Stunden' : 'hours'}</option>
|
||||
<option value="days">{language === 'de' ? 'Tage' : 'days'}</option>
|
||||
</select>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--weekly-text-light)', whiteSpace: 'nowrap' }}>
|
||||
{language === 'de' ? 'vorher' : 'before'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => confirmCustomReminder(idx)}
|
||||
title={language === 'de' ? 'Bestätigen' : 'Confirm'}
|
||||
style={{
|
||||
background: '#3b82f6', border: 'none', borderRadius: '5px',
|
||||
cursor: 'pointer', padding: '3px 7px', display: 'flex', alignItems: 'center',
|
||||
color: '#fff', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCustomEditorIdx(null)}
|
||||
title={language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.6 }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{reminders.length > 0 && reminders.length < 2 && (
|
||||
|
||||
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,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, Suspense } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
|
||||
import { Repeat, Circle, X, ChevronDown, Link } from "lucide-react";
|
||||
import { Repeat, X, ChevronDown, Link } from "lucide-react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
@ -267,10 +267,10 @@ export function GridTaskBlock({
|
||||
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
|
||||
if (stageColor) return `4px solid ${stageColor}`;
|
||||
if (task.project?.color) return `4px solid ${task.project.color}`;
|
||||
return (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none";
|
||||
return `4px solid transparent`;
|
||||
})(),
|
||||
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
|
||||
padding: (task.kanbanStage && kanbanStages.some(s => s.id === task.kanbanStage)) || task.project?.color ? "2px 4px 2px 8px" : "2px 4px",
|
||||
padding: weatherEnabled ? "2px 30px 2px 8px" : "2px 4px 2px 8px",
|
||||
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@ -299,7 +299,7 @@ export function GridTaskBlock({
|
||||
if (editingTaskId !== task.id) toggleTask(task.id);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
|
||||
{editingTaskId === task.id ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@ -329,7 +329,7 @@ export function GridTaskBlock({
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
overflow: "visible",
|
||||
whiteSpace: "pre-wrap",
|
||||
@ -341,6 +341,37 @@ export function GridTaskBlock({
|
||||
setEditingTaskId(task.id);
|
||||
}}
|
||||
>
|
||||
{(showPriorityIcons || showProjectIcons) && (
|
||||
<span style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "14px", minWidth: "14px", flexShrink: 0, gap: "2px", opacity: task.completed && showTaskCheckboxes ? 0.5 : 1 }}>
|
||||
{showPriorityIcons && (() => {
|
||||
const pb = getPriorityBadge(task, priorityStyle, 11);
|
||||
return pb ? (
|
||||
<span title={pb.label} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: "12px", height: "12px" }}>
|
||||
{pb.node}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{showProjectIcons && task.project && (() => {
|
||||
const rawIcon = task.project!.icon || "";
|
||||
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
|
||||
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
|
||||
: rawIcon;
|
||||
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: "12px", height: "12px" }}>
|
||||
{found
|
||||
? (found.type === "fa"
|
||||
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />
|
||||
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ display: "inline-block" }} />)
|
||||
: (task.project!.icon
|
||||
? <span style={{ fontSize: "11px" }}>{task.project!.icon}</span>
|
||||
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />)
|
||||
}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{showTaskCheckboxes && (
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -349,34 +380,11 @@ export function GridTaskBlock({
|
||||
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="task-checkbox flex-shrink-0"
|
||||
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }}
|
||||
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", flexShrink: 0, accentColor: "#FFF" }}
|
||||
/>
|
||||
)}
|
||||
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
|
||||
{showPriorityIcons && (() => {
|
||||
const pb = getPriorityBadge(task, priorityStyle, 11);
|
||||
return pb ? (
|
||||
<span title={pb.label} style={{ marginRight: "4px", display: "inline-flex", alignItems: "center", flexShrink: 0 }}>
|
||||
{pb.node}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
<span style={{ marginRight: "4px" }}>{showProjectIcons && task.project && (() => {
|
||||
const rawIcon = task.project!.icon || "";
|
||||
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
|
||||
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
|
||||
: rawIcon;
|
||||
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
|
||||
const iconStyle = { marginRight: "4px", verticalAlign: "middle" } as const;
|
||||
if (found) {
|
||||
return found.type === "fa"
|
||||
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />
|
||||
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ ...iconStyle, display: "inline-block" }} />;
|
||||
}
|
||||
return task.project!.icon
|
||||
? <span style={{ fontSize: "11px", ...iconStyle }}>{task.project!.icon}</span>
|
||||
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />;
|
||||
})()}{task.title}</span>
|
||||
<span style={{ marginRight: "4px" }}>{task.title}</span>
|
||||
{/* Subtask indicator */}
|
||||
{task.subTasks && task.subTasks.length > 0 && (() => {
|
||||
const completed = task.subTasks.filter(s => s.completed).length;
|
||||
@ -488,53 +496,42 @@ export function GridTaskBlock({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{weatherEnabled && (() => {
|
||||
const provider = task.externalProvider
|
||||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||||
if (!provider) return null;
|
||||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
||||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||||
};
|
||||
const info = iconMap[provider];
|
||||
if (!info) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
aria-label={`Synced with ${info.label}`}
|
||||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "3px" }}
|
||||
>
|
||||
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!weatherEnabled && (() => {
|
||||
const provider = task.externalProvider
|
||||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||||
if (!provider) return null;
|
||||
{editingTaskId !== task.id && (() => {
|
||||
const rolling = task.isRolling && !task.completed;
|
||||
const provider = task.externalProvider || (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||||
if (!rolling && !provider) return null;
|
||||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
||||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||||
};
|
||||
return (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px", flexShrink: 0 }}>
|
||||
{rolling && (
|
||||
<span title="Auto-rolling task" style={{ display: "inline-flex", alignItems: "center", color: "var(--weekly-teal, #009a9a)" }}>
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{provider && (() => {
|
||||
const info = iconMap[provider];
|
||||
if (!info) return null;
|
||||
return (
|
||||
<span
|
||||
aria-label={`Synced with ${info.label}`}
|
||||
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
|
||||
>
|
||||
<span className="flex-shrink-0" aria-label={`Synced with ${info.label}`} style={{ display: "inline-flex", alignItems: "center", opacity: 0.55 }}>
|
||||
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="task-actions"
|
||||
@ -618,13 +615,21 @@ export function GridTaskBlock({
|
||||
aria-expanded={showProjectPicker}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Circle
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
fill={task.project?.color || "none"}
|
||||
stroke={task.project?.color || "currentColor"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{task.project ? (() => {
|
||||
const rawIcon = task.project.icon || "";
|
||||
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
|
||||
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
|
||||
: rawIcon;
|
||||
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
|
||||
if (found) {
|
||||
return found.type === "fa"
|
||||
? <FontAwesomeIcon icon={found.icon as IconDefinition} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />
|
||||
: <MdiIcon path={found.icon as string} size={0.5} color={task.project.color || "#888"} style={{ display: "inline-block" }} />;
|
||||
}
|
||||
return task.project.icon
|
||||
? <span style={{ fontSize: "11px", color: task.project.color || "#888" }}>{task.project.icon}</span>
|
||||
: <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />;
|
||||
})() : <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px" }} />}
|
||||
</button>
|
||||
{showProjectPicker && (
|
||||
<div className="absolute z-[100] top-full left-1/2 -translate-x-1/2 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
|
||||
@ -650,7 +655,21 @@ export function GridTaskBlock({
|
||||
setShowProjectPicker(false);
|
||||
}}
|
||||
>
|
||||
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
|
||||
{(() => {
|
||||
const rawIcon = p.icon || "";
|
||||
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
|
||||
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
|
||||
: rawIcon;
|
||||
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
|
||||
if (found) {
|
||||
return found.type === "fa"
|
||||
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "10px", color: p.color || "#888" }} />
|
||||
: <MdiIcon path={found.icon as string} size={0.45} color={p.color || "#888"} style={{ display: "inline-block" }} />;
|
||||
}
|
||||
return p.icon
|
||||
? <span style={{ fontSize: "10px", color: p.color || "#888" }}>{p.icon}</span>
|
||||
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "10px", color: p.color || "#888" }} />;
|
||||
})()}
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGoogle, faApple, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import FontPicker from "./FontPicker";
|
||||
import FlagIcon from "./FlagIcon";
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
profile: any;
|
||||
@ -755,7 +756,7 @@ export default function OnboardingWizard({
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.75rem", background: darkMode ? "#374151" : "#e5e7eb", padding: "2px 6px", borderRadius: "4px" }}>{lang.flag}</span>
|
||||
<FlagIcon code={lang.code} width={22} height={16} />
|
||||
{lang.label}
|
||||
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
|
||||
</button>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -35,6 +35,357 @@ import {
|
||||
import IconPicker from "./IconPicker";
|
||||
import { allIcons } from "./iconRegistry";
|
||||
import Icon from "@mdi/react";
|
||||
import FlagIcon from "./FlagIcon";
|
||||
import SearchableDropdown from "./SearchableDropdown";
|
||||
import { TIMEZONE_OPTIONS, formatTimezone, getOffsetMinutes, formatOffset } from "../lib/timezones";
|
||||
|
||||
// Punkt 6 — manage a user's extra timezones (for cross-team scheduling).
|
||||
// Stored in user.viewSettings.extraTimezones to avoid an extra migration.
|
||||
function ExtraTimezonesEditor({
|
||||
profile,
|
||||
setProfile,
|
||||
saveSetting,
|
||||
}: {
|
||||
profile: any;
|
||||
setProfile: React.Dispatch<React.SetStateAction<any>>;
|
||||
saveSetting: (key: string, value: any) => void;
|
||||
}) {
|
||||
const de = profile.language === "de";
|
||||
const extras: string[] = (profile.viewSettings && profile.viewSettings.extraTimezones) || [];
|
||||
const [picker, setPicker] = useState("");
|
||||
|
||||
const persist = (next: string[]) => {
|
||||
const newViewSettings = { ...(profile.viewSettings || {}), extraTimezones: next };
|
||||
setProfile((p: any) => ({ ...p, viewSettings: newViewSettings }));
|
||||
saveSetting("viewSettings", newViewSettings);
|
||||
};
|
||||
const add = (zone: string) => {
|
||||
if (!zone || extras.includes(zone) || zone === profile.timezone) return;
|
||||
persist([...extras, zone]);
|
||||
setPicker("");
|
||||
};
|
||||
const remove = (zone: string) => {
|
||||
persist(extras.filter((z) => z !== zone));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<label style={{ display: "block", fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", marginBottom: "4px" }}>
|
||||
{de ? "Zusätzliche Zeitzonen" : "Additional Timezones"}
|
||||
</label>
|
||||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 6px", opacity: 0.8 }}>
|
||||
{de
|
||||
? "Hilfreich, wenn du mit Teammitgliedern in anderen Zeitzonen arbeitest. Zeiten werden im Tageskopf angezeigt."
|
||||
: "Useful when collaborating with people in other timezones. Times are shown in the day header."}
|
||||
</p>
|
||||
{extras.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", marginBottom: "6px" }}>
|
||||
{extras.map((z) => {
|
||||
const opt = TIMEZONE_OPTIONS.find((o) => o.zone === z);
|
||||
return (
|
||||
<div key={z} style={{
|
||||
display: "flex", alignItems: "center", gap: "8px",
|
||||
padding: "4px 8px", borderRadius: "6px",
|
||||
background: "var(--weekly-bg-soft, #f9fafb)",
|
||||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||
}}>
|
||||
<span style={{ fontSize: "0.8rem", flex: 1 }}>
|
||||
{opt ? formatTimezone(opt) : z}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => remove(z)}
|
||||
title={de ? "Entfernen" : "Remove"}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "6px" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<SearchableDropdown
|
||||
value={picker}
|
||||
onChange={(v) => setPicker(v)}
|
||||
searchable
|
||||
placeholder={de ? "Zeitzone auswählen…" : "Select a timezone…"}
|
||||
searchPlaceholder={de ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||||
emptyText={de ? "Keine Treffer" : "No matches"}
|
||||
options={TIMEZONE_OPTIONS
|
||||
.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone)
|
||||
.sort((a, b) => {
|
||||
const oa = getOffsetMinutes(a.zone);
|
||||
const ob = getOffsetMinutes(b.zone);
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.code.localeCompare(b.code);
|
||||
})
|
||||
.map((opt) => {
|
||||
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||||
return {
|
||||
value: opt.zone,
|
||||
label: `${opt.code} — ${opt.label}`,
|
||||
secondary: `UTC${off}`,
|
||||
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => add(picker)}
|
||||
disabled={!picker}
|
||||
className="weekly-btn-primary"
|
||||
style={{ padding: "6px 10px", fontSize: "0.85rem", display: "inline-flex", alignItems: "center", gap: "4px", opacity: picker ? 1 : 0.5 }}
|
||||
>
|
||||
<Plus size={14} /> {de ? "Hinzufügen" : "Add"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// All customizable color custom-properties defined in globals.css :root, grouped for the
|
||||
// "Styling" tab's CSS Variables editor. Overrides are stored as a flat { "--var-name": "#hex" }
|
||||
// map in profile.customCssVars and applied on top of containerStyle in WeeklyView. Entries with
|
||||
// a `profileKey` instead read/write that scalar profile field directly (same fields the
|
||||
// "Element Colors" / "Weekend Highlight Colors" controls elsewhere in this tab use), so both
|
||||
// controls for the same setting always stay in sync.
|
||||
const CSS_VARIABLE_GROUPS: { title: string; vars: { name: string; label: string; default: string; profileKey?: string }[] }[] = [
|
||||
{
|
||||
title: "Element Colors",
|
||||
vars: [
|
||||
{ name: "todayHighlightColor", label: "Today Highlight", default: "#f0fafa", profileKey: "todayHighlightColor" },
|
||||
{ name: "pastDayColor", label: "Past Days", default: "#a6a6a7", profileKey: "pastDayColor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Weekend Highlight Colors",
|
||||
vars: [
|
||||
{ name: "weekendColorSat", label: "Saturday", default: "#666666", profileKey: "weekendColorSat" },
|
||||
{ name: "weekendColorSun", label: "Sunday", default: "#dc2626", profileKey: "weekendColorSun" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Base Colors",
|
||||
vars: [
|
||||
{ name: "--weekly-teal", label: "Accent", default: "#009a9a" },
|
||||
{ name: "--weekly-bg", label: "Background", default: "#ffffff" },
|
||||
{ name: "--weekly-text", label: "Text", default: "#000000" },
|
||||
{ name: "--weekly-text-light", label: "Text (light)", default: "#767676" },
|
||||
{ name: "--weekly-border", label: "Border", default: "#cccccc" },
|
||||
{ name: "--weekly-completed", label: "Completed task", default: "#cccccc" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings Sidebar",
|
||||
vars: [
|
||||
{ name: "--weekly-settings-bg", label: "Background", default: "#ffffff" },
|
||||
{ name: "--weekly-settings-item-bg", label: "Item background", default: "#f8fafc" },
|
||||
{ name: "--weekly-settings-label", label: "Label", default: "#475569" },
|
||||
{ name: "--weekly-settings-title", label: "Title", default: "#111827" },
|
||||
{ name: "--weekly-settings-input-bg", label: "Input background", default: "#ffffff" },
|
||||
{ name: "--weekly-settings-input-border", label: "Input border", default: "#dddddd" },
|
||||
{ name: "--weekly-settings-text", label: "Text", default: "#111827" },
|
||||
{ name: "--weekly-settings-toggle-bg", label: "Toggle background", default: "#f3f4f6" },
|
||||
{ name: "--weekly-settings-toggle-active-bg", label: "Toggle active background", default: "#ffffff" },
|
||||
{ name: "--weekly-settings-toggle-text", label: "Toggle text", default: "#6b7280" },
|
||||
{ name: "--weekly-settings-toggle-active-text", label: "Toggle active text", default: "#111827" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Design Token Aliases",
|
||||
vars: [
|
||||
{ name: "--bg", label: "Background (alias)", default: "#ffffff" },
|
||||
{ name: "--paper", label: "Paper (alias)", default: "#ffffff" },
|
||||
{ name: "--ink", label: "Text (alias)", default: "#000000" },
|
||||
{ name: "--ink-2", label: "Text variant 2", default: "#2a2a28" },
|
||||
{ name: "--ink-3", label: "Text light (alias)", default: "#767676" },
|
||||
{ name: "--ink-4", label: "Text variant 4", default: "#a8a8a2" },
|
||||
{ name: "--ink-5", label: "Text variant 5", default: "#c8c8c2" },
|
||||
{ name: "--accent", label: "Accent (alias)", default: "#009a9a" },
|
||||
{ name: "--line", label: "Border (alias)", default: "#cccccc" },
|
||||
{ name: "--line-soft", label: "Border (soft)", default: "#f3f3f0" },
|
||||
{ name: "--weekend", label: "Weekend", default: "#dc2626" },
|
||||
{ name: "--tasks-bg", label: "Tasks background", default: "#f4f2ee" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Event Colors — Default",
|
||||
vars: [
|
||||
{ name: "--ev-default-bg", label: "Background", default: "#eef2f7" },
|
||||
{ name: "--ev-default-border", label: "Border", default: "#6c87a8" },
|
||||
{ name: "--ev-default-title", label: "Title", default: "#2b3f57" },
|
||||
{ name: "--ev-default-meta", label: "Meta", default: "#6c87a8" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Event Colors — Family",
|
||||
vars: [
|
||||
{ name: "--ev-fam-bg", label: "Background", default: "#fbeef0" },
|
||||
{ name: "--ev-fam-border", label: "Border", default: "#b85a6a" },
|
||||
{ name: "--ev-fam-title", label: "Title", default: "#5a2530" },
|
||||
{ name: "--ev-fam-meta", label: "Meta", default: "#99536a" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Event Colors — Finance",
|
||||
vars: [
|
||||
{ name: "--ev-fin-bg", label: "Background", default: "#f6f0e2" },
|
||||
{ name: "--ev-fin-border", label: "Border", default: "#a88a3c" },
|
||||
{ name: "--ev-fin-title", label: "Title", default: "#4a3a14" },
|
||||
{ name: "--ev-fin-meta", label: "Meta", default: "#8a6f2a" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Event Colors — Development",
|
||||
vars: [
|
||||
{ name: "--ev-dev-bg", label: "Background", default: "#ecf3ed" },
|
||||
{ name: "--ev-dev-border", label: "Border", default: "#5a7a4a" },
|
||||
{ name: "--ev-dev-title", label: "Title", default: "#2c4527" },
|
||||
{ name: "--ev-dev-meta", label: "Meta", default: "#5a7a4a" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "All-Day Chips",
|
||||
vars: [
|
||||
{ name: "--chip-default-bg", label: "Default", default: "#b8b8b0" },
|
||||
{ name: "--chip-fam-bg", label: "Family", default: "#e88a8a" },
|
||||
{ name: "--chip-special-bg", label: "Special background", default: "#f4d588" },
|
||||
{ name: "--chip-special-text", label: "Special text", default: "#6b4f10" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Lets the user override any globals.css :root color variable individually. Overrides are
|
||||
// merged into profile.customCssVars (a flat map) and spread onto the root container's inline
|
||||
// style in WeeklyView, so they take precedence over every other color source (theme, per-field
|
||||
// settings, etc.).
|
||||
function CssVariablesEditor({
|
||||
profile,
|
||||
setProfile,
|
||||
saveSetting,
|
||||
}: {
|
||||
profile: any;
|
||||
setProfile: React.Dispatch<React.SetStateAction<any>>;
|
||||
saveSetting: (key: string, value: any) => void;
|
||||
}) {
|
||||
const de = profile.language === "de";
|
||||
const vars: Record<string, string> = profile.customCssVars || {};
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const fieldDebounceTimers = useRef<Record<string, NodeJS.Timeout>>({});
|
||||
|
||||
const setVar = (name: string, value: string) => {
|
||||
const next = { ...(profile.customCssVars || {}), [name]: value };
|
||||
setProfile((p: any) => ({ ...p, customCssVars: next }));
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => saveSetting("customCssVars", next), 500);
|
||||
};
|
||||
|
||||
const setProfileField = (key: string, value: string) => {
|
||||
setProfile((p: any) => ({ ...p, [key]: value }));
|
||||
if (fieldDebounceTimers.current[key]) clearTimeout(fieldDebounceTimers.current[key]);
|
||||
fieldDebounceTimers.current[key] = setTimeout(() => saveSetting(key, value), 500);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setProfile((p: any) => ({ ...p, customCssVars: {} }));
|
||||
saveSetting("customCssVars", {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--weekly-settings-item-bg)",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "12px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>
|
||||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||||
{de ? "CSS-Variablen" : "CSS Variables"}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
background: "none",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
borderRadius: "6px",
|
||||
padding: "3px 8px",
|
||||
cursor: "pointer",
|
||||
color: "var(--weekly-settings-label)",
|
||||
}}
|
||||
>
|
||||
{de ? "Zurücksetzen" : "Reset"}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 10px", opacity: 0.8 }}>
|
||||
{de
|
||||
? "Passe jede Design-Farbvariable des Stylesheets einzeln an."
|
||||
: "Fine-tune every design-token color used by the stylesheet."}
|
||||
</p>
|
||||
{CSS_VARIABLE_GROUPS.map((group) => (
|
||||
<div key={group.title} style={{ marginBottom: "12px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--weekly-settings-title)",
|
||||
marginBottom: "6px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 12px" }}>
|
||||
{group.vars.map((v) => {
|
||||
const value = v.profileKey ? (profile[v.profileKey] || v.default) : (vars[v.name] || v.default);
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
v.profileKey ? setProfileField(v.profileKey, e.target.value) : setVar(v.name, e.target.value);
|
||||
return (
|
||||
<div
|
||||
key={v.name}
|
||||
title={v.name}
|
||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "22px",
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
borderRadius: "4px",
|
||||
background: "transparent",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--weekly-settings-label)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{v.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal ProjectIcon — resolves an icon name from the unified registry.
|
||||
function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) {
|
||||
@ -842,11 +1193,11 @@ function SettingsSidebar({
|
||||
className="weekly-settings-tabs"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px",
|
||||
justifyContent: "space-around",
|
||||
flexWrap: "nowrap",
|
||||
gap: "0",
|
||||
borderBottom: "1px solid var(--weekly-border, #eee)",
|
||||
padding: "0 24px",
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
{([
|
||||
@ -854,7 +1205,7 @@ function SettingsSidebar({
|
||||
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
|
||||
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
||||
{ key: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" },
|
||||
{ key: "projects", icon: <Briefcase size={18} />, label: t.projects || "Projects" },
|
||||
{ key: "projects", icon: <FolderOpen size={18} />, label: t.projects || "Projects" },
|
||||
{ key: "account", icon: <User size={18} />, label: t.account },
|
||||
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
|
||||
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
|
||||
@ -866,7 +1217,7 @@ function SettingsSidebar({
|
||||
title={tab.label}
|
||||
className="settings-tab-btn"
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
padding: "10px 8px",
|
||||
borderBottom:
|
||||
activeTab === tab.key
|
||||
? "2px solid var(--weekly-text, black)"
|
||||
@ -904,6 +1255,74 @@ function SettingsSidebar({
|
||||
>
|
||||
{/* ── General settings (not view-specific) ── */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingBottom: "16px", borderBottom: "1px solid var(--weekly-border, #e5e7eb)" }}>
|
||||
{/* Menu Position */}
|
||||
<div>
|
||||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||||
{profile.language === "de" ? "Menüposition" : "Menu Position"}
|
||||
</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{[
|
||||
{ value: "left", label: profile.language === "de" ? "Links (Seitenleiste)" : "Left (Sidebar)" },
|
||||
{ value: "top", label: profile.language === "de" ? "Oben (Kopfleiste)" : "Top (Header)" },
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setProfile((p: any) => ({ ...p, menuPosition: value }));
|
||||
saveSetting("menuPosition", value);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 10px",
|
||||
fontSize: "0.8rem",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||
cursor: "pointer",
|
||||
fontWeight: (profile.menuPosition || "left") === value ? 700 : 400,
|
||||
background: (profile.menuPosition || "left") === value ? "var(--weekly-text, #333)" : "transparent",
|
||||
color: (profile.menuPosition || "left") === value ? "#fff" : "var(--weekly-settings-label)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<label style={{ fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||||
{profile.language === "de" ? "Steuerung in der Kopfleiste" : "Header Controls"}
|
||||
</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{[
|
||||
{ value: true, label: profile.language === "de" ? "Anzeigen" : "Show" },
|
||||
{ value: false, label: profile.language === "de" ? "Ausblenden" : "Hide" },
|
||||
].map(({ value, label }) => {
|
||||
const isActive = (profile.showHeaderControls !== false) === value;
|
||||
return (
|
||||
<button
|
||||
key={String(value)}
|
||||
onClick={() => {
|
||||
setProfile((p: any) => ({ ...p, showHeaderControls: value }));
|
||||
saveSetting("showHeaderControls", value);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 10px",
|
||||
fontSize: "0.8rem",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||
cursor: "pointer",
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
background: isActive ? "var(--weekly-text, #333)" : "transparent",
|
||||
color: isActive ? "#fff" : "var(--weekly-settings-label)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Header Display */}
|
||||
<div>
|
||||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||||
@ -1598,23 +2017,17 @@ function SettingsSidebar({
|
||||
>
|
||||
{t.language}
|
||||
</label>
|
||||
<select
|
||||
<SearchableDropdown
|
||||
value={profile.language || "de"}
|
||||
onChange={(e) => saveField("language", e.target.value)}
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
</select>
|
||||
onChange={(v) => saveField("language", v)}
|
||||
options={[
|
||||
{ value: "en", label: "English", leading: <FlagIcon code="en" width={20} height={14} /> },
|
||||
{ value: "de", label: "Deutsch", leading: <FlagIcon code="de" width={20} height={14} /> },
|
||||
{ value: "fr", label: "Français", leading: <FlagIcon code="fr" width={20} height={14} /> },
|
||||
{ value: "es", label: "Español", leading: <FlagIcon code="es" width={20} height={14} /> },
|
||||
{ value: "it", label: "Italiano", leading: <FlagIcon code="it" width={20} height={14} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -1628,19 +2041,37 @@ function SettingsSidebar({
|
||||
>
|
||||
{t.timezone}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px",
|
||||
fontSize: "0.9rem",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
borderRadius: "4px",
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
</div>
|
||||
<SearchableDropdown
|
||||
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
onChange={(v) => saveField("timezone", v)}
|
||||
searchable
|
||||
searchPlaceholder={profile.language === "de" ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||||
emptyText={profile.language === "de" ? "Keine Treffer" : "No matches"}
|
||||
options={[...TIMEZONE_OPTIONS]
|
||||
.sort((a, b) => {
|
||||
const oa = getOffsetMinutes(a.zone);
|
||||
const ob = getOffsetMinutes(b.zone);
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.code.localeCompare(b.code);
|
||||
})
|
||||
.map((opt) => {
|
||||
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||||
return {
|
||||
value: opt.zone,
|
||||
label: `${opt.code} — ${opt.label}`,
|
||||
secondary: `UTC${off}`,
|
||||
// Match against IANA name (zone), code, label,
|
||||
// and the offset in several styles.
|
||||
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{/* Multi-timezone (Punkt 6) */}
|
||||
<ExtraTimezonesEditor
|
||||
profile={profile}
|
||||
setProfile={setProfile}
|
||||
saveSetting={saveSetting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -3523,6 +3954,55 @@ function SettingsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS Variables */}
|
||||
<CssVariablesEditor profile={profile} setProfile={setProfile} saveSetting={saveSetting} />
|
||||
|
||||
{/* Custom CSS */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--weekly-settings-item-bg)",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "12px",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--weekly-settings-label)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{profile.language === "de" ? "Eigenes CSS" : "Custom CSS"}
|
||||
</label>
|
||||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 8px", opacity: 0.8 }}>
|
||||
{profile.language === "de"
|
||||
? "Wird nach dem Stylesheet geladen und kann jede Regel überschreiben."
|
||||
: "Loaded after the stylesheet — can override any rule."}
|
||||
</p>
|
||||
<textarea
|
||||
value={profile.customCss || ""}
|
||||
onChange={(e) => saveFieldDebounced("customCss", e.target.value)}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
placeholder={".weekly-task-item.completed .weekly-task-text {\n color: #cccccc;\n}"}
|
||||
style={{
|
||||
width: "100%",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
borderRadius: "6px",
|
||||
padding: "8px",
|
||||
resize: "vertical",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* All styling settings auto-save */}
|
||||
</div>
|
||||
) : activeTab === "motivation" ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ export const authOptions: NextAuthOptions = {
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
allowDangerousEmailAccountLinking: false,
|
||||
authorization: {
|
||||
params: {
|
||||
prompt: "consent",
|
||||
@ -26,13 +26,13 @@ export const authOptions: NextAuthOptions = {
|
||||
AppleProvider({
|
||||
clientId: process.env.APPLE_ID || "",
|
||||
clientSecret: process.env.APPLE_SECRET || "",
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
allowDangerousEmailAccountLinking: false,
|
||||
}),
|
||||
AzureADProvider({
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID || "",
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
|
||||
tenantId: "common",
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
allowDangerousEmailAccountLinking: false,
|
||||
authorization: {
|
||||
params: {
|
||||
prompt: "consent",
|
||||
@ -85,7 +85,7 @@ export const authOptions: NextAuthOptions = {
|
||||
}
|
||||
})
|
||||
],
|
||||
debug: true,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
|
||||
@ -78,6 +78,7 @@ export async function readCachedEvents(
|
||||
calendarId: row.calendarId,
|
||||
calendarTitle: row.calendarTitle,
|
||||
calendarColor: row.calendarColor,
|
||||
busyStatus: row.busyStatus ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -128,6 +129,7 @@ export async function refreshConnectionCache(
|
||||
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
|
||||
endDate: ev.end.date ?? null,
|
||||
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
|
||||
busyStatus: ev.busyStatus ?? null,
|
||||
weekStart,
|
||||
syncedAt: now,
|
||||
}));
|
||||
@ -183,6 +185,7 @@ export async function upsertCachedEvent(
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
endDate: event.end.date ?? null,
|
||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||
busyStatus: event.busyStatus ?? null,
|
||||
weekStart,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
@ -200,6 +203,7 @@ export async function upsertCachedEvent(
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
endDate: event.end.date ?? null,
|
||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||
busyStatus: event.busyStatus ?? null,
|
||||
weekStart,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
@ -214,7 +218,22 @@ export async function deleteCachedEvent(
|
||||
externalId: string,
|
||||
provider: string,
|
||||
): Promise<void> {
|
||||
// For Outlook recurring events, our composite ID is "seriesMasterId::instanceId".
|
||||
// When the user deletes the whole series, every cached occurrence of that series
|
||||
// must be cleared too — otherwise the surviving rows reappear after the next read.
|
||||
const seriesMasterId = externalId.includes('::') ? externalId.split('::')[0] : externalId;
|
||||
|
||||
await prisma.cachedCalendarEvent.deleteMany({
|
||||
where: { userId, externalId, provider },
|
||||
where: {
|
||||
userId,
|
||||
provider,
|
||||
OR: [
|
||||
{ externalId },
|
||||
{ externalId: seriesMasterId },
|
||||
{ recurringEventId: seriesMasterId },
|
||||
// The composite IDs of all instances start with "<seriesMasterId>::"
|
||||
{ externalId: { startsWith: `${seriesMasterId}::` } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -863,8 +863,13 @@ export const createCalendarEvent = async (
|
||||
source: 'outlook',
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
isRecurring: !!event.recurrence,
|
||||
recurringEventId: event.recurrence ? createdEvent.id : undefined,
|
||||
isRecurring: createdEvent.isRecurring ?? !!event.recurrence,
|
||||
recurringEventId: createdEvent.recurringEventId ?? (event.recurrence ? createdEvent.id : undefined),
|
||||
reminders: createdEvent.reminders,
|
||||
busyStatus: createdEvent.busyStatus as BusyStatus | undefined,
|
||||
visibility: createdEvent.visibility as EventVisibility | undefined,
|
||||
attendees: createdEvent.attendees as EventAttendee[] | undefined,
|
||||
url: createdEvent.htmlLink,
|
||||
} as CalendarEvent;
|
||||
} else if (connection.provider === 'apple') {
|
||||
const [email, appPassword] = connection.accessToken.split(':');
|
||||
@ -1114,6 +1119,13 @@ export const updateCalendarEvent = async (
|
||||
source: 'outlook',
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
isRecurring: updatedEvent.isRecurring,
|
||||
recurringEventId: updatedEvent.recurringEventId,
|
||||
reminders: updatedEvent.reminders,
|
||||
busyStatus: updatedEvent.busyStatus as BusyStatus | undefined,
|
||||
visibility: updatedEvent.visibility as EventVisibility | undefined,
|
||||
attendees: updatedEvent.attendees as EventAttendee[] | undefined,
|
||||
url: updatedEvent.htmlLink,
|
||||
} as CalendarEvent;
|
||||
} else if (connection.provider === 'apple') {
|
||||
const [email, appPassword] = connection.accessToken.split(':');
|
||||
@ -1342,6 +1354,16 @@ export const deleteCalendarEvent = async (
|
||||
} else if (deleteMode === 'all' || !hasInstanceId) {
|
||||
// Delete the entire series (use series master ID)
|
||||
await deleteOutlookEvent(accessToken, calendarId, seriesMasterId);
|
||||
// Safety net: Outlook sometimes leaves the first occurrence as an orphan
|
||||
// after deleting the seriesMaster (especially when the master's start
|
||||
// matches the first occurrence). Explicitly delete the instance ID too.
|
||||
if (hasInstanceId && instanceId && instanceId !== seriesMasterId) {
|
||||
try {
|
||||
await deleteOutlookEvent(accessToken, calendarId, instanceId);
|
||||
} catch (e) {
|
||||
// Ignore — the master delete is the authoritative operation.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 'future' or 'past' — Outlook doesn't support partial series delete easily
|
||||
// Fall back to deleting the series
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
||||
|
||||
let _transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
@ -26,10 +27,16 @@ function getTransporter() {
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
code: string,
|
||||
token: string
|
||||
token: string,
|
||||
language?: string | null,
|
||||
) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||
// Point the button at the client verify page (not the API GET handler).
|
||||
// This stops corporate email scanners (M365 Safe Links, etc.) from
|
||||
// pre-fetching the URL and silently consuming the one-shot token before
|
||||
// the user ever sees the email — which is the bug behind point #2.
|
||||
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`;
|
||||
const t = getVerificationCopy(language);
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
@ -43,59 +50,32 @@ export async function sendVerificationEmail(
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 32px 40px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
|
||||
My Weekly ToDo's
|
||||
</h1>
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding: 16px 40px;">
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
|
||||
Welcome! Please verify your email address to complete your registration.
|
||||
</p>
|
||||
|
||||
<!-- Code Box -->
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.welcome}</p>
|
||||
<div style="background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px; padding: 24px; text-align: center; margin: 0 0 24px;">
|
||||
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">
|
||||
Your verification code
|
||||
</p>
|
||||
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">
|
||||
${code}
|
||||
</p>
|
||||
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">${t.yourCode}</p>
|
||||
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">${code}</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">
|
||||
Or click the button below to verify instantly:
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">${t.orClick}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 0 24px;">
|
||||
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
|
||||
✓ Verify Email
|
||||
</a>
|
||||
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
Simple. Beautiful. Yours.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -108,23 +88,22 @@ export async function sendVerificationEmail(
|
||||
|
||||
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
|
||||
console.log(`[EMAIL] Sending verification email to ${email} from ${from}`);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`\n======================================================`);
|
||||
console.log(`[DEV VERIFICATION LINK]:\n${verifyLink}`);
|
||||
console.log(`======================================================\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 15 second timeout for SMTP
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
||||
);
|
||||
|
||||
const mailPromise = getTransporter().sendMail({
|
||||
from,
|
||||
to: email,
|
||||
subject: `${code} – Verify your email for My Weekly ToDo's`,
|
||||
subject: t.subject(code),
|
||||
html,
|
||||
});
|
||||
|
||||
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
||||
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
|
||||
} catch (err: any) {
|
||||
@ -132,9 +111,10 @@ export async function sendVerificationEmail(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email: string, token: string) {
|
||||
export async function sendPasswordResetEmail(email: string, token: string, language?: string | null) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
const resetLink = `${baseUrl}/auth/reset-password?token=${token}`;
|
||||
const t = getResetCopy(language);
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
@ -150,35 +130,25 @@ export async function sendPasswordResetEmail(email: string, token: string) {
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 32px 40px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
|
||||
My Weekly ToDo's
|
||||
</h1>
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 16px 40px;">
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
|
||||
We received a request to reset your password. Click the button below to choose a new one.
|
||||
</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.intro}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 0 24px;">
|
||||
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
|
||||
Reset Password
|
||||
</a>
|
||||
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
Simple. Beautiful. Yours.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -191,22 +161,22 @@ export async function sendPasswordResetEmail(email: string, token: string) {
|
||||
|
||||
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
|
||||
console.log(`[EMAIL] Sending password reset email to ${email} from ${from}`);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`\n======================================================`);
|
||||
console.log(`[DEV RESET LINK]:\n${resetLink}`);
|
||||
console.log(`======================================================\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
||||
);
|
||||
|
||||
const mailPromise = getTransporter().sendMail({
|
||||
from,
|
||||
to: email,
|
||||
subject: `Password Reset – My Weekly ToDo's`,
|
||||
subject: t.subject,
|
||||
html,
|
||||
});
|
||||
|
||||
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
||||
console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`);
|
||||
} catch (err: any) {
|
||||
|
||||
135
src/lib/emailTemplates.ts
Normal file
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)
|
||||
*/
|
||||
|
||||
@ -87,6 +87,32 @@ export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTo
|
||||
return (data.value || []) as MicrosoftTodoList[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a Microsoft To-Do task list.
|
||||
*/
|
||||
export const deleteMsTodoList = async (
|
||||
accessToken: string,
|
||||
listId: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 404/410 mean it's already gone — treat as success
|
||||
if (response.status === 404 || response.status === 410) return;
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Failed to delete To-Do list: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch active tasks from a specific To-Do list (for import).
|
||||
*/
|
||||
@ -158,6 +184,78 @@ export const fetchMsTodoTasksForSync = async (
|
||||
return allTasks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Microsoft Graph delta sync: returns only the tasks that changed (created,
|
||||
* updated, or deleted) since the previous delta token, plus a fresh
|
||||
* `deltaToken` to use on the next call. Pass `null` to start a new chain.
|
||||
*
|
||||
* Deleted tasks come back with an `@removed` field; we surface them via
|
||||
* the optional `_deleted` boolean on the task object.
|
||||
*
|
||||
* Cost: typically a few hundred bytes per call when nothing changed.
|
||||
*/
|
||||
export const fetchMsTodoTasksDelta = async (
|
||||
accessToken: string,
|
||||
listId: string,
|
||||
previousDeltaToken: string | null,
|
||||
): Promise<{ tasks: (MicrosoftTodoTask & { _deleted?: boolean })[]; deltaToken: string | null }> => {
|
||||
const tasks: (MicrosoftTodoTask & { _deleted?: boolean })[] = [];
|
||||
let url: string | null;
|
||||
if (previousDeltaToken) {
|
||||
// Resume from where we left off
|
||||
url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta?$deltatoken=${encodeURIComponent(previousDeltaToken)}`;
|
||||
} else {
|
||||
// Initial sync — Graph will paginate via @odata.nextLink, then return @odata.deltaLink
|
||||
url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta`;
|
||||
}
|
||||
|
||||
let deltaToken: string | null = null;
|
||||
|
||||
while (url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
// 410 Gone = delta token expired (>30 days). Caller should retry with null.
|
||||
if (response.status === 410) {
|
||||
throw Object.assign(new Error('Delta token expired'), { code: 'DELTA_EXPIRED' });
|
||||
}
|
||||
console.error(`Error fetching delta from list ${listId}:`, err);
|
||||
throw new Error(`Failed to fetch To-Do delta: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
for (const item of (data.value || [])) {
|
||||
if (item['@removed']) {
|
||||
tasks.push({ ...item, _deleted: true } as any);
|
||||
} else {
|
||||
tasks.push(item as MicrosoftTodoTask);
|
||||
}
|
||||
}
|
||||
|
||||
const nextLink: string | undefined = data['@odata.nextLink'];
|
||||
const deltaLink: string | undefined = data['@odata.deltaLink'];
|
||||
|
||||
if (deltaLink) {
|
||||
// Final page — extract the new delta token
|
||||
const match = deltaLink.match(/[?&]\$deltatoken=([^&]+)/);
|
||||
deltaToken = match ? decodeURIComponent(match[1]) : null;
|
||||
url = null;
|
||||
} else if (nextLink) {
|
||||
url = nextLink;
|
||||
} else {
|
||||
url = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { tasks, deltaToken };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new Microsoft To-Do task in a list.
|
||||
*/
|
||||
@ -168,6 +266,7 @@ export const createMsTodoTask = async (
|
||||
title: string;
|
||||
body?: string;
|
||||
dueDateTime?: string;
|
||||
importance?: 'low' | 'normal' | 'high';
|
||||
}
|
||||
): Promise<MicrosoftTodoTask> => {
|
||||
const requestBody: any = { title: taskData.title };
|
||||
@ -181,6 +280,9 @@ export const createMsTodoTask = async (
|
||||
timeZone: 'UTC'
|
||||
};
|
||||
}
|
||||
if (taskData.importance) {
|
||||
requestBody.importance = taskData.importance;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`,
|
||||
@ -214,6 +316,7 @@ export const updateMsTodoTask = async (
|
||||
body?: string;
|
||||
status?: 'notStarted' | 'completed';
|
||||
dueDateTime?: string | null;
|
||||
importance?: 'low' | 'normal' | 'high';
|
||||
}
|
||||
): Promise<MicrosoftTodoTask> => {
|
||||
const body: any = {};
|
||||
@ -236,6 +339,7 @@ export const updateMsTodoTask = async (
|
||||
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
|
||||
: null;
|
||||
}
|
||||
if (updates.importance !== undefined) body.importance = updates.importance;
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
|
||||
@ -201,54 +201,7 @@ export const getUpcomingEvents = async (
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.value.map((event: any) => {
|
||||
// Map Outlook showAs to our busyStatus
|
||||
const showAsMap: Record<string, string> = {
|
||||
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
|
||||
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
|
||||
};
|
||||
// Map Outlook sensitivity to our visibility
|
||||
const sensitivityMap: Record<string, string> = {
|
||||
'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential',
|
||||
};
|
||||
|
||||
// Outlook returns dateTime without Z suffix even when timeZone is UTC.
|
||||
// Append Z so JS Date parsing treats it as UTC (not local time).
|
||||
const fixUtc = (dt: string, tz: string) =>
|
||||
dt && tz === 'UTC' && !dt.endsWith('Z') ? dt + 'Z' : dt;
|
||||
|
||||
return {
|
||||
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
|
||||
summary: event.subject,
|
||||
description: event.body?.content || event.bodyPreview,
|
||||
start: {
|
||||
dateTime: fixUtc(event.start.dateTime, event.start.timeZone),
|
||||
timeZone: event.start.timeZone
|
||||
},
|
||||
end: {
|
||||
dateTime: fixUtc(event.end.dateTime, event.end.timeZone),
|
||||
timeZone: event.end.timeZone
|
||||
},
|
||||
location: event.location?.displayName,
|
||||
htmlLink: event.webLink,
|
||||
allDay: event.isAllDay,
|
||||
recurringEventId: event.seriesMasterId || undefined,
|
||||
isRecurring: event.type === 'occurrence' || event.type === 'exception' || event.type === 'seriesMaster',
|
||||
reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null
|
||||
? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }]
|
||||
: undefined,
|
||||
busyStatus: showAsMap[event.showAs] || undefined,
|
||||
visibility: sensitivityMap[event.sensitivity] || undefined,
|
||||
attendees: event.attendees?.map((a: any) => ({
|
||||
email: a.emailAddress?.address,
|
||||
displayName: a.emailAddress?.name,
|
||||
responseStatus: a.status?.response === 'accepted' ? 'accepted'
|
||||
: a.status?.response === 'declined' ? 'declined'
|
||||
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
|
||||
: 'needsAction',
|
||||
})),
|
||||
};
|
||||
});
|
||||
return data.value.map((event: any) => mapOutlookEventResponse(event));
|
||||
};
|
||||
|
||||
const ensureTimeZone = (dateTimeObj: any) => {
|
||||
@ -283,6 +236,45 @@ const normalizeOutlookDateTime = (dtObj: any) => {
|
||||
return { dateTime: dt, timeZone: dtObj.timeZone };
|
||||
};
|
||||
|
||||
// Map a raw Microsoft Graph event response into our internal shape.
|
||||
// Used after create/update to keep the full set of fields (busyStatus, visibility,
|
||||
// attendees, reminders, recurrence info) flowing back to the cache + UI.
|
||||
const mapOutlookEventResponse = (ev: any) => {
|
||||
const showAsMap: Record<string, string> = {
|
||||
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
|
||||
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
|
||||
};
|
||||
const sensitivityMap: Record<string, string> = {
|
||||
'normal': 'default', 'personal': 'default',
|
||||
'private': 'private', 'confidential': 'confidential',
|
||||
};
|
||||
return {
|
||||
id: ev.seriesMasterId ? `${ev.seriesMasterId}::${ev.id}` : ev.id,
|
||||
summary: ev.subject,
|
||||
description: ev.body?.content || ev.bodyPreview,
|
||||
start: normalizeOutlookDateTime(ev.start),
|
||||
end: normalizeOutlookDateTime(ev.end),
|
||||
location: ev.location?.displayName,
|
||||
htmlLink: ev.webLink,
|
||||
allDay: ev.isAllDay,
|
||||
recurringEventId: ev.seriesMasterId || undefined,
|
||||
isRecurring: ev.type === 'occurrence' || ev.type === 'exception' || ev.type === 'seriesMaster',
|
||||
reminders: ev.isReminderOn && ev.reminderMinutesBeforeStart != null
|
||||
? [{ method: 'popup', minutes: ev.reminderMinutesBeforeStart }]
|
||||
: undefined,
|
||||
busyStatus: showAsMap[ev.showAs] || undefined,
|
||||
visibility: sensitivityMap[ev.sensitivity] || undefined,
|
||||
attendees: ev.attendees?.map((a: any) => ({
|
||||
email: a.emailAddress?.address,
|
||||
displayName: a.emailAddress?.name,
|
||||
responseStatus: a.status?.response === 'accepted' ? 'accepted'
|
||||
: a.status?.response === 'declined' ? 'declined'
|
||||
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
|
||||
: 'needsAction',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const createEvent = async (
|
||||
accessToken: string,
|
||||
calendarId: string,
|
||||
@ -336,15 +328,7 @@ export const createEvent = async (
|
||||
}
|
||||
|
||||
const created = await response.json();
|
||||
return {
|
||||
id: created.id,
|
||||
summary: created.subject,
|
||||
description: created.bodyPreview,
|
||||
start: normalizeOutlookDateTime(created.start),
|
||||
end: normalizeOutlookDateTime(created.end),
|
||||
location: created.location?.displayName,
|
||||
allDay: created.isAllDay
|
||||
};
|
||||
return mapOutlookEventResponse(created);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -419,15 +403,7 @@ export const updateEvent = async (
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
return {
|
||||
id: updated.id,
|
||||
summary: updated.subject,
|
||||
description: updated.bodyPreview,
|
||||
start: normalizeOutlookDateTime(updated.start),
|
||||
end: normalizeOutlookDateTime(updated.end),
|
||||
location: updated.location?.displayName,
|
||||
allDay: updated.isAllDay
|
||||
};
|
||||
return mapOutlookEventResponse(updated);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
97
src/lib/timezones.ts
Normal file
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": {
|
||||
"target": "es2017",
|
||||
"downlevelIteration": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
@ -14,7 +13,7 @@
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user