This commit is contained in:
@@ -806,6 +806,8 @@ if static_dir.exists():
|
|||||||
|
|
||||||
@app.get("/{path:path}", include_in_schema=False)
|
@app.get("/{path:path}", include_in_schema=False)
|
||||||
async def spa(path: str):
|
async def spa(path: str):
|
||||||
|
if path == "api" or path.startswith("api/"):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
root = static_dir.resolve()
|
root = static_dir.resolve()
|
||||||
target = (root / path).resolve()
|
target = (root / path).resolve()
|
||||||
headers = {"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"}
|
headers = {"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"}
|
||||||
|
|||||||
+3
-122
@@ -4,7 +4,6 @@ import re
|
|||||||
from datetime import UTC, date, datetime, time, timedelta
|
from datetime import UTC, date, datetime, time, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -127,7 +126,6 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut
|
|||||||
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
|
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
|
||||||
"""Return True when `at` is the exact timestamp of an occurrence this rule generates."""
|
"""Return True when `at` is the exact timestamp of an occurrence this rule generates."""
|
||||||
parts = parse_rrule(rule)
|
parts = parse_rrule(rule)
|
||||||
interval = int(parts.get("INTERVAL", 1))
|
|
||||||
if starts.tzinfo is None:
|
if starts.tzinfo is None:
|
||||||
starts = starts.replace(tzinfo=UTC)
|
starts = starts.replace(tzinfo=UTC)
|
||||||
else:
|
else:
|
||||||
@@ -143,38 +141,9 @@ def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
|
|||||||
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC)
|
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC)
|
||||||
if at > until:
|
if at > until:
|
||||||
return False
|
return False
|
||||||
if "COUNT" in parts:
|
# Exact-match validation via the same canonical generator used for recurrence
|
||||||
count = int(parts["COUNT"])
|
# mutations, so COUNT/UNTIL, time-of-day, BYDAY/BYMONTHDAY and sparse yearly
|
||||||
match = 0
|
# rules all share one behavior.
|
||||||
cursor = starts
|
|
||||||
guard = 0
|
|
||||||
while cursor <= at and guard < 40000:
|
|
||||||
if parts["FREQ"] == "DAILY":
|
|
||||||
include = (cursor.date() - starts.date()).days % interval == 0
|
|
||||||
elif parts["FREQ"] == "WEEKLY":
|
|
||||||
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
|
|
||||||
include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0
|
|
||||||
elif parts["FREQ"] == "MONTHLY":
|
|
||||||
month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month
|
|
||||||
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
|
|
||||||
include = month_delta % interval == 0 and cursor.day in month_days
|
|
||||||
else:
|
|
||||||
years = cursor.year - starts.year
|
|
||||||
months = {int(x) for x in parts.get("BYMONTH", str(starts.month)).split(",")}
|
|
||||||
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
|
|
||||||
include = years % interval == 0 and cursor.month in months and cursor.day in month_days
|
|
||||||
if include and cursor >= starts:
|
|
||||||
match += 1
|
|
||||||
if cursor == at:
|
|
||||||
return True
|
|
||||||
if match >= count:
|
|
||||||
return False
|
|
||||||
guard += 1
|
|
||||||
cursor += timedelta(days=1)
|
|
||||||
return False
|
|
||||||
# Exact-match validation via the same canonical day generator used for
|
|
||||||
# rendering, so time-of-day is preserved and nothing outside the rule is
|
|
||||||
# accepted as a valid occurrence.
|
|
||||||
candidates = occurrences(rule, starts, starts, at, None)
|
candidates = occurrences(rule, starts, starts, at, None)
|
||||||
return at in candidates
|
return at in candidates
|
||||||
|
|
||||||
@@ -222,94 +191,6 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
|||||||
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
|
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendar")
|
|
||||||
async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$"), timezone_offset: int | None = Query(default=None, ge=-840, le=840), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
|
||||||
if end < start or (end - start).days > 366:
|
|
||||||
raise HTTPException(422, "日期范围无效或超过一年")
|
|
||||||
try:
|
|
||||||
zone = ZoneInfo(timezone)
|
|
||||||
except ZoneInfoNotFoundError:
|
|
||||||
raise HTTPException(422, "未知时区")
|
|
||||||
if timezone_offset is not None and timezone == "UTC":
|
|
||||||
# Legacy clients pass an offset instead of an IANA name.
|
|
||||||
zone = ZoneInfo("UTC")
|
|
||||||
start_dt = datetime.combine(start, time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset)
|
|
||||||
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset) - timedelta(microseconds=1)
|
|
||||||
else:
|
|
||||||
start_dt = datetime.combine(start, time.min, tzinfo=zone).astimezone(UTC)
|
|
||||||
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone).astimezone(UTC) - timedelta(microseconds=1)
|
|
||||||
|
|
||||||
def as_utc(value: datetime) -> datetime:
|
|
||||||
if value.tzinfo is None:
|
|
||||||
return value.replace(tzinfo=UTC)
|
|
||||||
return value.astimezone(UTC)
|
|
||||||
|
|
||||||
recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
|
|
||||||
template_ids = [template.id for template, _ in recurrence_rows]
|
|
||||||
exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all())
|
|
||||||
exceptions_by_template = {}
|
|
||||||
for exception in exception_rows:
|
|
||||||
key = as_utc(exception.occurrence_at)
|
|
||||||
exceptions_by_template.setdefault(exception.template_id, {})[key] = exception
|
|
||||||
|
|
||||||
recurring_task_ids = {task.id for _, task in recurrence_rows}
|
|
||||||
normal_query = select(Task).where(
|
|
||||||
Task.user_id == user.id,
|
|
||||||
Task.deleted_at.is_(None),
|
|
||||||
Task.parent_id.is_(None),
|
|
||||||
Task.due_at >= start_dt,
|
|
||||||
Task.due_at <= end_dt,
|
|
||||||
)
|
|
||||||
if recurring_task_ids:
|
|
||||||
normal_query = normal_query.where(Task.id.not_in(recurring_task_ids))
|
|
||||||
normal_tasks = list((await db.scalars(normal_query)).all())
|
|
||||||
output = [{
|
|
||||||
"id": task.id,
|
|
||||||
"recurrence_id": None,
|
|
||||||
"task_id": task.id,
|
|
||||||
"occurrence_at": task.due_at,
|
|
||||||
"title": task.title,
|
|
||||||
"due_at": task.due_at,
|
|
||||||
"completed": task.completed,
|
|
||||||
"version": task.version,
|
|
||||||
} for task in normal_tasks]
|
|
||||||
emitted = set()
|
|
||||||
for template, task in recurrence_rows:
|
|
||||||
exceptions = exceptions_by_template.get(template.id, {})
|
|
||||||
# Canonical series from the template's true start so every exception
|
|
||||||
# whose occurrence is real and inside this series is considered.
|
|
||||||
series_start = template.starts_at
|
|
||||||
if exceptions:
|
|
||||||
first_exception = min(as_utc(at) for at in exceptions)
|
|
||||||
series_start = min(as_utc(series_start), first_exception)
|
|
||||||
generated = occurrences(template.rrule, template.starts_at, as_utc(series_start), end_dt, template.ends_at)
|
|
||||||
for at in generated:
|
|
||||||
exception = exceptions.get(as_utc(at))
|
|
||||||
if exception and exception.deleted:
|
|
||||||
continue
|
|
||||||
due_at = as_utc(exception.due_at) if exception and exception.due_at else at
|
|
||||||
if due_at < start_dt or due_at > end_dt:
|
|
||||||
continue
|
|
||||||
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": due_at, "completed": bool(exception and exception.completed)})
|
|
||||||
emitted.add(as_utc(at))
|
|
||||||
# Fall back for stored exceptions whose original slot is real but which
|
|
||||||
# the canonical generation skipped only because their original date is
|
|
||||||
# outside the requested window (e.g. moved backwards across the month).
|
|
||||||
for occurrence_at, exception in exceptions.items():
|
|
||||||
if exception.deleted or exception.due_at is None:
|
|
||||||
continue
|
|
||||||
if occurrence_at in emitted:
|
|
||||||
continue
|
|
||||||
if not is_occurrence(template.rrule, template.starts_at, occurrence_at):
|
|
||||||
continue
|
|
||||||
if template.ends_at and occurrence_at > as_utc(template.ends_at):
|
|
||||||
continue
|
|
||||||
due_at = as_utc(exception.due_at)
|
|
||||||
if start_dt <= due_at <= end_dt:
|
|
||||||
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": due_at, "completed": bool(exception.completed)})
|
|
||||||
return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"])
|
|
||||||
|
|
||||||
|
|
||||||
async def upsert_exception(db, template_id, at):
|
async def upsert_exception(db, template_id, at):
|
||||||
row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at))
|
row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at))
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-Dq8LoBCn.js"></script>
|
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-CmIuUToo.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CpBTIN38.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-nhCaMkMR.css">
|
||||||
</head><body><div id="app"></div></body></html>
|
</head><body><div id="app"></div></body></html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const CACHE = 'dodo-shell-v2'
|
const CACHE = 'dodo-shell-v3'
|
||||||
const SHELL = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
|
const SHELL = ['/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
self.skipWaiting()
|
self.skipWaiting()
|
||||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
|
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
|
||||||
@@ -11,11 +11,16 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const url = new URL(event.request.url)
|
const url = new URL(event.request.url)
|
||||||
if (url.pathname.startsWith('/api/')) return
|
if (url.pathname.startsWith('/api/')) return
|
||||||
if (event.request.method !== 'GET') return
|
if (event.request.method !== 'GET') return
|
||||||
|
const isNavigation = event.request.mode === 'navigate'
|
||||||
|
if (isNavigation) {
|
||||||
|
event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')))
|
||||||
|
return
|
||||||
|
}
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
||||||
const copy = response.clone()
|
const copy = response.clone()
|
||||||
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
|
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
|
||||||
return response
|
return response
|
||||||
}).catch(() => caches.match('/'))),
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@fullcalendar/core":"^6.1.21","@fullcalendar/daygrid":"^6.1.21","@fullcalendar/interaction":"^6.1.21","@fullcalendar/vue3":"^6.1.21","@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
||||||
Generated
-53
@@ -8,18 +8,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fullcalendar/core':
|
|
||||||
specifier: ^6.1.21
|
|
||||||
version: 6.1.21
|
|
||||||
'@fullcalendar/daygrid':
|
|
||||||
specifier: ^6.1.21
|
|
||||||
version: 6.1.21(@fullcalendar/[email protected])
|
|
||||||
'@fullcalendar/interaction':
|
|
||||||
specifier: ^6.1.21
|
|
||||||
version: 6.1.21(@fullcalendar/[email protected])
|
|
||||||
'@fullcalendar/vue3':
|
|
||||||
specifier: ^6.1.21
|
|
||||||
version: 6.1.21(@fullcalendar/[email protected])([email protected]([email protected]))
|
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 6.0.8([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
|
version: 6.0.8([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
|
||||||
@@ -158,25 +146,6 @@ packages:
|
|||||||
'@floating-ui/[email protected]':
|
'@floating-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
||||||
|
|
||||||
'@fullcalendar/[email protected]':
|
|
||||||
resolution: {integrity: sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==}
|
|
||||||
|
|
||||||
'@fullcalendar/[email protected]':
|
|
||||||
resolution: {integrity: sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==}
|
|
||||||
peerDependencies:
|
|
||||||
'@fullcalendar/core': ~6.1.21
|
|
||||||
|
|
||||||
'@fullcalendar/[email protected]':
|
|
||||||
resolution: {integrity: sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==}
|
|
||||||
peerDependencies:
|
|
||||||
'@fullcalendar/core': ~6.1.21
|
|
||||||
|
|
||||||
'@fullcalendar/[email protected]':
|
|
||||||
resolution: {integrity: sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==}
|
|
||||||
peerDependencies:
|
|
||||||
'@fullcalendar/core': ~6.1.21
|
|
||||||
vue: ^3.0.11
|
|
||||||
|
|
||||||
'@internationalized/[email protected]':
|
'@internationalized/[email protected]':
|
||||||
resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==}
|
resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==}
|
||||||
|
|
||||||
@@ -879,9 +848,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1267,23 +1233,6 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@fullcalendar/[email protected]':
|
|
||||||
dependencies:
|
|
||||||
preact: 10.12.1
|
|
||||||
|
|
||||||
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
|
|
||||||
dependencies:
|
|
||||||
'@fullcalendar/core': 6.1.21
|
|
||||||
|
|
||||||
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
|
|
||||||
dependencies:
|
|
||||||
'@fullcalendar/core': 6.1.21
|
|
||||||
|
|
||||||
'@fullcalendar/[email protected](@fullcalendar/[email protected])([email protected]([email protected]))':
|
|
||||||
dependencies:
|
|
||||||
'@fullcalendar/core': 6.1.21
|
|
||||||
vue: 3.5.42([email protected])
|
|
||||||
|
|
||||||
'@internationalized/[email protected]':
|
'@internationalized/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/helpers': 0.5.23
|
'@swc/helpers': 0.5.23
|
||||||
@@ -1889,8 +1838,6 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|||||||
import {
|
import {
|
||||||
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
|
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
|
||||||
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
||||||
Settings, Trash2, X, CalendarRange, Repeat2,
|
Settings, Trash2, X, Repeat2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
||||||
import { isTaskView } from './lib/mvp-utils'
|
import { isTaskView } from './lib/mvp-utils'
|
||||||
@@ -14,7 +14,7 @@ type FolderItem = { id: string; name: string }
|
|||||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||||
type Tag = { id: string; name: string; color: string }
|
type Tag = { id: string; name: string; color: string }
|
||||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
|
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
|
||||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'calendar' | 'habits' | 'settings'
|
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'settings'
|
||||||
|
|
||||||
const initialized = ref<boolean | null>(null)
|
const initialized = ref<boolean | null>(null)
|
||||||
const authenticated = ref(false)
|
const authenticated = ref(false)
|
||||||
@@ -48,7 +48,6 @@ const activeName = computed(() => {
|
|||||||
if (activeView.value === 'trash') return '回收站'
|
if (activeView.value === 'trash') return '回收站'
|
||||||
if (activeView.value === 'today') return '今天'
|
if (activeView.value === 'today') return '今天'
|
||||||
if (activeView.value === 'upcoming') return '最近 7 天'
|
if (activeView.value === 'upcoming') return '最近 7 天'
|
||||||
if (activeView.value === 'calendar') return '月历'
|
|
||||||
if (activeView.value === 'habits') return '习惯'
|
if (activeView.value === 'habits') return '习惯'
|
||||||
if (activeView.value === 'settings') return '设置与数据'
|
if (activeView.value === 'settings') return '设置与数据'
|
||||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||||
@@ -58,7 +57,7 @@ const visibleTasks = computed(() => {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const end = new Date(now); end.setDate(end.getDate() + 7)
|
const end = new Date(now); end.setDate(end.getDate() + 7)
|
||||||
let result = sourceTasks.value
|
let result = sourceTasks.value
|
||||||
if (['calendar','habits','settings'].includes(activeView.value)) return []
|
if (['habits','settings'].includes(activeView.value)) return []
|
||||||
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
||||||
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
||||||
return query.value.trim() ? filterTasks(result, query.value) : result
|
return query.value.trim() ? filterTasks(result, query.value) : result
|
||||||
@@ -202,7 +201,6 @@ async function switchView(view: View, listId?: string) {
|
|||||||
page.value = 1
|
page.value = 1
|
||||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
|
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
|
||||||
if (view === 'trash') await loadTrash()
|
if (view === 'trash') await loadTrash()
|
||||||
else if (view === 'calendar') return
|
|
||||||
else if (!isTaskView(view)) tasks.value = []
|
else if (!isTaskView(view)) tasks.value = []
|
||||||
else await loadAll()
|
else await loadAll()
|
||||||
}
|
}
|
||||||
@@ -314,7 +312,6 @@ onMounted(bootstrap)
|
|||||||
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
|
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
|
||||||
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
||||||
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
||||||
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarRange />月历</button>
|
|
||||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||||
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
|
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -335,8 +332,8 @@ onMounted(bootstrap)
|
|||||||
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
|
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
|
||||||
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||||
</header>
|
</header>
|
||||||
<template v-if="['calendar','habits','settings'].includes(activeView)">
|
<template v-if="['habits','settings'].includes(activeView)">
|
||||||
<MvpPanel :key="activeView" :view="activeView as 'calendar'|'habits'|'settings'" @changed="refreshAll" @notice="toast" />
|
<MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
||||||
@@ -376,7 +373,7 @@ onMounted(bootstrap)
|
|||||||
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期、优先级、标签、子任务和 Markdown 备注会出现在这里。</p></div>
|
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期、优先级、标签、子任务和 Markdown 备注会出现在这里。</p></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='calendar'}" @click="switchView('calendar')"><CalendarRange/><span>月历</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||||
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
||||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||||
|
|||||||
+191
-80
@@ -1,23 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import FullCalendar from '@fullcalendar/vue3'
|
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue } from './lib/mvp-utils'
|
||||||
import interactionPlugin from '@fullcalendar/interaction'
|
|
||||||
import type { CalendarOptions, EventDropArg } from '@fullcalendar/core'
|
|
||||||
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
|
||||||
import { dateKey, habitWeek, mergePage, moveDueDate } from './lib/mvp-utils'
|
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
|
|
||||||
type View = 'calendar'|'habits'|'settings'
|
type View = 'habits' | 'settings'
|
||||||
type Task = { id:string; title:string; due_at:string|null; version:number; recurrence_id?:string|null }
|
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }>; stats?: Record<string, number> }
|
||||||
type Habit = { id:string; name:string; kind?:string; target?:number; max_value?:number|null; unit?:string; cells?: Array<{day:string; scheduled?:boolean; paused?:boolean; value:number|boolean}>; stats?: Record<string,number> }
|
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
|
||||||
type Session = { id:string; created_at?:string; last_seen_at?:string; current?:boolean; user_agent?:string }
|
const props = defineProps<{ view: View }>()
|
||||||
const props = defineProps<{ view:View }>()
|
const emit = defineEmits<{ changed: []; notice: [message: string] }>()
|
||||||
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
|
const habits = ref<Habit[]>([])
|
||||||
const habits = ref<Habit[]>([]), calendarTasks = ref<Task[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
|
const sessions = ref<Session[]>([])
|
||||||
const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('boolean'), habitTarget = ref(1)
|
const audit = ref<any[]>([])
|
||||||
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
|
const busy = ref(false)
|
||||||
const week = computed(() => habitWeek())
|
const error = ref('')
|
||||||
|
const habitName = ref('')
|
||||||
|
const habitType = ref<'boolean' | 'numeric'>('boolean')
|
||||||
|
const habitTarget = ref(1)
|
||||||
|
const importFile = ref<File | null>(null)
|
||||||
|
const importPreview = ref<any>(null)
|
||||||
|
const restoreFile = ref<File | null>(null)
|
||||||
|
const numericValues = ref<Record<string, number>>({})
|
||||||
|
const todayKey = ref(dateKey(new Date()))
|
||||||
|
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
function formatErrorMessage(detail: unknown): string {
|
function formatErrorMessage(detail: unknown): string {
|
||||||
if (typeof detail === 'string') return detail
|
if (typeof detail === 'string') return detail
|
||||||
@@ -35,78 +40,184 @@ function formatErrorMessage(detail: unknown): string {
|
|||||||
return '请求失败'
|
return '请求失败'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path:string, options:RequestInit={}) {
|
async function request(path: string, options: RequestInit = {}) {
|
||||||
const headers:Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
|
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) }
|
||||||
if (options.body && !(options.body instanceof FormData)) headers['Content-Type']='application/json'
|
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
|
||||||
const csrf = csrfHeader(options.method)
|
const csrf = csrfHeader(options.method)
|
||||||
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
|
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
|
||||||
const response = await fetch('/api/v1'+path,{ credentials:'include',...options,headers })
|
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}))
|
const body = await response.json().catch(() => ({}))
|
||||||
throw new Error(formatErrorMessage((body as { detail?: unknown }).detail))
|
throw new Error(formatErrorMessage((body as { detail?: unknown }).detail))
|
||||||
}
|
}
|
||||||
const type=response.headers.get('content-type')||''
|
const type = response.headers.get('content-type') || ''
|
||||||
return response.status===204?null:type.includes('json')?response.json():response.blob()
|
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob()
|
||||||
}
|
}
|
||||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try{await work()}catch(e){error.value=e instanceof Error?e.message:'请求失败'}finally{busy.value=false} }
|
async function safe(work: () => Promise<void>) {
|
||||||
async function loadHabits(){ await safe(async()=>{const grid=await request(`/habits/grid?week=${dateKey(week.value[0])}`); habits.value=(grid as {habits?: Habit[]}).habits ?? [];}) }
|
busy.value = true; error.value = ''
|
||||||
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),kind:habitType.value,target:habitTarget.value,schedule_type:'daily'})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
|
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : '请求失败' } finally { busy.value = false }
|
||||||
function logFor(h:Habit,day:string){return (h.cells??[]).find(c=>c.day===day)}
|
}
|
||||||
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{const current=logFor(h,day);const next=h.kind==='numeric'?(value??h.target??1):(current?.value?0:1);await request(`/habits/${h.id}/logs/${day}`,{method:'PUT',body:JSON.stringify({value:next})});await loadHabits();emit('notice','打卡已记录')})}
|
|
||||||
async function deleteHabit(h:Habit){if(!confirm(`删除习惯“${h.name}”?`))return;await safe(async()=>{await request(`/habits/${h.id}`,{method:'DELETE'});await loadHabits()})}
|
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
|
||||||
const currentCalendarRange = ref<{ startStr:string; endStr:string }|null>(null)
|
function isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h, day)?.value, h.target ?? 1) }
|
||||||
async function loadCalendar(range?: { startStr?: string; endStr?: string }) {
|
async function toggleHabit(h: Habit, day: string) {
|
||||||
if (range?.startStr && range?.endStr) currentCalendarRange.value = { startStr: range.startStr, endStr: range.endStr }
|
if (busy.value) return
|
||||||
const now = new Date()
|
const current = logFor(h, day)
|
||||||
const activeRange = currentCalendarRange.value
|
const next = current?.value ? 0 : 1
|
||||||
const start = activeRange?.startStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth(), 1))
|
await safe(async () => {
|
||||||
const endExclusiveKey = activeRange?.endStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth() + 1, 1))
|
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||||||
const endExclusive = new Date(`${endExclusiveKey}T00:00:00`)
|
await loadHabits()
|
||||||
endExclusive.setDate(endExclusive.getDate() - 1)
|
emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
|
||||||
const end = dateKey(endExclusive)
|
|
||||||
await safe(async()=>{
|
|
||||||
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
||||||
const rows = await request(`/calendar?start=${start}&end=${end}&timezone=${encodeURIComponent(tzName)}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}>
|
|
||||||
calendarTasks.value = rows.map(row=>({
|
|
||||||
id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id),
|
|
||||||
title: row.title,
|
|
||||||
due_at: row.due_at ?? row.occurrence_at,
|
|
||||||
version: row.version ?? 0,
|
|
||||||
recurrence_id: row.recurrence_id,
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async function moveTask(arg:EventDropArg){const task=calendarTasks.value.find(t=>t.id===arg.event.id);if(!task||task.version===0)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});await loadCalendar();emit('notice','日期已更新')}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
|
async function recordNumeric(h: Habit, day: string) {
|
||||||
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:calendarTasks.value.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!,editable:t.version!==0})),datesSet:loadCalendar,eventDrop:moveTask}))
|
if (busy.value) return
|
||||||
async function loadSettings(){await safe(async()=>{const [s,a]=await Promise.all([request('/sessions').catch(()=>[]),request('/audit-logs?limit=20').catch(()=>[])]);sessions.value=mergePage<Session>(s).items;audit.value=mergePage<any>(a).items})}
|
const next = numericHabitInputValue(numericValues.value[h.id])
|
||||||
async function revoke(id:string){await safe(async()=>{await request(`/sessions/${id}`,{method:'DELETE'});await loadSettings();emit('notice','会话已撤销')})}
|
if (next === null || (h.max_value != null && next > h.max_value)) return
|
||||||
function downloadBlob(blob:Blob,name:string){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
await safe(async () => {
|
||||||
async function exportData(){await safe(async()=>downloadBlob(await request('/export'),'dodo-export.json'))}
|
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||||||
async function previewImport(){if(!importFile.value)return;await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);importPreview.value=await request('/import/ticktick/preview',{method:'POST',body:form})})}
|
await loadHabits()
|
||||||
async function confirmImport(){await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);const result=await request('/import/ticktick',{method:'POST',body:form});importPreview.value=null;emit('changed');emit('notice',`导入完成:新增 ${result?.imported??0},跳过 ${result?.skipped??0}`)})}
|
emit('notice', next > 0 ? '已记录 🎉' : '已清零')
|
||||||
async function restore(){if(!restoreFile.value)return;if(!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?'))return;await safe(async()=>{const text=await restoreFile.value!.text();await request('/restore?mode=merge',{method:'POST',body:text});emit('changed');emit('notice','数据已恢复')})}
|
})
|
||||||
onMounted(()=>props.view==='habits'?loadHabits():props.view==='settings'?loadSettings():undefined)
|
}
|
||||||
|
async function addHabit() {
|
||||||
|
if (!habitName.value.trim()) return
|
||||||
|
await safe(async () => {
|
||||||
|
await request('/habits', { method: 'POST', body: JSON.stringify({ name: habitName.value.trim(), kind: habitType.value, target: habitTarget.value, schedule_type: 'daily' }) })
|
||||||
|
habitName.value = ''
|
||||||
|
await loadHabits()
|
||||||
|
emit('notice', '习惯已创建')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function archiveHabit(h: Habit) {
|
||||||
|
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
|
||||||
|
await safe(async () => {
|
||||||
|
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
||||||
|
await loadHabits()
|
||||||
|
emit('notice', '习惯已归档')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function refreshHabitDay() {
|
||||||
|
const next = dateKey(new Date())
|
||||||
|
if (next !== todayKey.value) {
|
||||||
|
todayKey.value = next
|
||||||
|
if (props.view === 'habits') void loadHabits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadHabits() {
|
||||||
|
await safe(async () => {
|
||||||
|
const data = await request(`/habits/grid?week=${dateKey(new Date())}`) as { habits?: Habit[] }
|
||||||
|
habits.value = data.habits ?? []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function loadSettings() {
|
||||||
|
await safe(async () => {
|
||||||
|
const [s, a] = await Promise.all([request('/sessions').catch(() => []), request('/audit-logs?limit=20').catch(() => [])])
|
||||||
|
sessions.value = mergePage<Session>(s).items
|
||||||
|
audit.value = mergePage<any>(a).items
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function revoke(id: string) {
|
||||||
|
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
|
||||||
|
}
|
||||||
|
function downloadBlob(blob: Blob, name: string) {
|
||||||
|
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
|
async function exportData() { await safe(async () => downloadBlob(await request('/export') as Blob, 'dodo-export.json')) }
|
||||||
|
async function previewImport() {
|
||||||
|
if (!importFile.value) return
|
||||||
|
await safe(async () => {
|
||||||
|
const form = new FormData(); form.append('file', importFile.value!)
|
||||||
|
importPreview.value = await request('/import/ticktick/preview', { method: 'POST', body: form })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function confirmImport() {
|
||||||
|
await safe(async () => {
|
||||||
|
const form = new FormData(); form.append('file', importFile.value!)
|
||||||
|
const result = await request('/import/ticktick', { method: 'POST', body: form }) as { imported?: number; skipped?: number }
|
||||||
|
importPreview.value = null
|
||||||
|
emit('changed'); emit('notice', `导入完成:新增 ${result?.imported ?? 0},跳过 ${result?.skipped ?? 0}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function restore() {
|
||||||
|
if (!restoreFile.value) return
|
||||||
|
if (!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?')) return
|
||||||
|
await safe(async () => {
|
||||||
|
const text = await restoreFile.value!.text()
|
||||||
|
await request('/restore?mode=merge', { method: 'POST', body: text })
|
||||||
|
emit('changed'); emit('notice', '数据已恢复')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.view === 'habits') {
|
||||||
|
refreshHabitDay()
|
||||||
|
void loadHabits()
|
||||||
|
dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
|
||||||
|
} else {
|
||||||
|
void loadSettings()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mvp-view" :class="{loading:busy}">
|
<section class="mvp-view" :class="{ loading: busy }">
|
||||||
<p v-if="error" class="inline-error">{{error}}</p>
|
<p v-if="error" class="inline-error">{{ error }}</p>
|
||||||
<template v-if="view==='calendar'">
|
|
||||||
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{calendarTasks.length}} 个已排期任务</span></header>
|
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
||||||
<div class="calendar-card"><FullCalendar :options="calendarOptions" /></div>
|
<template v-if="view === 'habits'">
|
||||||
</template>
|
<header class="view-intro">
|
||||||
<template v-else-if="view==='habits'">
|
<div><small>把想坚持的事,变成每天的日常</small><h2>习惯</h2></div>
|
||||||
<header class="view-intro"><div><small>今天做一点,明天更轻松</small><h2>习惯</h2></div><button class="soft-button" @click="loadHabits"><RefreshCw/>刷新</button></header>
|
<button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button>
|
||||||
<form class="habit-create" @submit.prevent="addHabit"><input v-model="habitName" placeholder="新习惯名称"><select v-model="habitType"><option value="boolean">完成 / 未完成</option><option value="numeric">数值</option></select><input v-if="habitType==='numeric'" v-model.number="habitTarget" type="number" min="0" step="any" aria-label="目标值"><button><Plus/>添加</button></form>
|
</header>
|
||||||
<div class="habit-list"><article v-for="h in habits" :key="h.id" class="habit-card"><div class="habit-title"><div><h3>{{h.name}}</h3><small v-if="h.stats">连续 {{h.stats.current_streak??0}} 天 · 完成率 {{Math.round((h.stats.completion_rate??0)*100)}}%</small></div><button class="icon ghost" aria-label="删除习惯" @click="deleteHabit(h)"><Trash2/></button></div><div class="week-grid"><div v-for="d in week" :key="dateKey(d)"><small>{{['一','二','三','四','五','六','日'][(d.getDay()+6)%7]}}<br>{{d.getDate()}}</small><button v-if="h.kind!=='numeric'" class="habit-check" :class="{done:logFor(h,dateKey(d))?.value}" @click="checkIn(h,dateKey(d))">{{logFor(h,dateKey(d))?.value?'✓':'·'}}</button><input v-else type="number" :value="logFor(h,dateKey(d))?.value??''" :placeholder="String(h.target??1)" @change="checkIn(h,dateKey(d),Number(($event.target as HTMLInputElement).value))"></div></div></article><div v-if="!habits.length&&!busy" class="empty-panel">还没有习惯,从一件容易坚持的小事开始。</div></div>
|
|
||||||
</template>
|
<!-- 新建习惯 -->
|
||||||
<template v-else>
|
<form class="habit-create" @submit.prevent="addHabit">
|
||||||
<header class="view-intro"><div><small>备份、迁移与安全</small><h2>设置与数据</h2></div></header>
|
<input v-model="habitName" placeholder="新习惯名称(如:喝水 8 杯)" aria-label="新习惯名称">
|
||||||
<div class="settings-grid">
|
<select v-model="habitType" aria-label="习惯类型">
|
||||||
<article class="tool-card"><FileJson/><h3>数据导出与恢复</h3><p>下载完整 JSON 备份,或从备份恢复。</p><button class="soft-button" @click="exportData"><Download/>导出 JSON</button><label class="file-button"><ArchiveRestore/>选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
|
<option value="boolean">完成 / 未完成</option>
|
||||||
<article class="tool-card"><Upload/><h3>导入</h3><p>先预览变化,确认后才写入。</p><label class="file-button">选择文件<input type="file" accept=".json,.csv" @change="importFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button :disabled="!importFile" class="soft-button" @click="previewImport">生成预览</button><pre v-if="importPreview">{{JSON.stringify(importPreview,null,2)}}</pre><button v-if="importPreview" class="primary-small" @click="confirmImport">确认导入</button></article>
|
<option value="numeric">按数量记录</option>
|
||||||
<article class="tool-card wide"><LogOut/><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{s.current?'当前设备':'其他设备'}}</b><small>{{s.user_agent||'未知设备'}} · {{s.last_seen_at||s.created_at}}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话。</p></article>
|
</select>
|
||||||
<article v-if="audit.length" class="tool-card wide"><Activity/><h3>最近活动</h3><div v-for="(row,i) in audit" :key="row.id||i" class="audit-row"><span>{{row.action||row.event||'变更'}}</span><small>{{row.created_at||row.timestamp}}</small></div></article>
|
<input v-if="habitType === 'numeric'" v-model.number="habitTarget" type="number" min="0" step="any" placeholder="目标值" aria-label="目标值">
|
||||||
</div>
|
<button class="primary-small"><Plus />添加</button>
|
||||||
</template>
|
</form>
|
||||||
</section>
|
|
||||||
|
|
||||||
|
<!-- 习惯列表:展示和操作分离,只点右侧明确按钮,降低误触 -->
|
||||||
|
<div class="habit-list">
|
||||||
|
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey) }">
|
||||||
|
<div v-if="h.kind !== 'numeric'" class="habit-main">
|
||||||
|
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已打卡' : '今天还没做' }}</small></span>
|
||||||
|
<button class="habit-check-button" :class="{ done: isDone(h, todayKey) }" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click="toggleHabit(h, todayKey)">
|
||||||
|
<Check v-if="isDone(h, todayKey)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="habit-main numeric-habit">
|
||||||
|
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
|
||||||
|
<span class="numeric-action">
|
||||||
|
<input v-model.number="numericValues[h.id]" type="number" min="0" :max="h.max_value ?? undefined" step="any" :placeholder="String(h.target || 1)" :aria-label="`${h.name}今日数值`">
|
||||||
|
<button class="soft-button" @click="recordNumeric(h, todayKey)">记录</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
|
||||||
|
</article>
|
||||||
|
<div v-if="!habits.length && !busy" class="empty-panel">还没有习惯,从一件容易坚持的小事开始。</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 设置与数据 -->
|
||||||
|
<template v-else>
|
||||||
|
<header class="view-intro">
|
||||||
|
<div><small>备份、迁移与安全</small><h2>设置与数据</h2></div>
|
||||||
|
</header>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<article class="tool-card"><FileJson /><h3>数据导出与恢复</h3><p>下载完整 JSON 备份,或从备份恢复。</p><button class="soft-button" @click="exportData"><Download />导出 JSON</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
|
||||||
|
<article class="tool-card"><Upload /><h3>导入</h3><p>先预览变化,确认后才写入。</p><label class="file-button">选择文件<input type="file" accept=".json,.csv" @change="importFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button :disabled="!importFile" class="soft-button" @click="previewImport">生成预览</button><pre v-if="importPreview">{{ JSON.stringify(importPreview, null, 2) }}</pre><button v-if="importPreview" class="primary-small" @click="confirmImport">确认导入</button></article>
|
||||||
|
<article class="tool-card wide"><LogOut /><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{ s.current ? '当前设备' : '其他设备' }}</b><small>{{ s.user_agent || '未知设备' }} · {{ s.last_seen_at || s.created_at }}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话。</p></article>
|
||||||
|
<article v-if="audit.length" class="tool-card wide"><Activity /><h3>最近活动</h3><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><span>{{ row.action || row.event || '变更' }}</span><small>{{ row.created_at || row.timestamp }}</small></div></article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { dateKey, isTaskView, moveDueDate } from './mvp-utils'
|
import { dateKey, habitWeek, isHabitComplete, isTaskView, numericHabitInputValue } from './mvp-utils'
|
||||||
|
|
||||||
describe('MVP view utilities', () => {
|
describe('MVP view utilities', () => {
|
||||||
it('normalizes to UTC so stored due_at stays stable in every local timezone', () => {
|
it('formats a local date as YYYY-MM-DD', () => {
|
||||||
const moved = moveDueDate('2026-09-05T14:30:00+08:00', '2026-09-09')
|
expect(dateKey(new Date(2026, 8, 5))).toBe('2026-09-05')
|
||||||
expect(moved).toBe('2026-09-09T14:30:00.000Z')
|
})
|
||||||
const newly = moveDueDate(null, '2026-09-09')
|
|
||||||
expect(newly).toBe('2026-09-09T09:00:00.000Z')
|
it('returns the monday-to-sunday week containing the requested day', () => {
|
||||||
|
expect(habitWeek(new Date(2026, 8, 5)).map(dateKey)).toEqual([
|
||||||
|
'2026-08-31', '2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04', '2026-09-05', '2026-09-06',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('identifies only task-backed views as task data loaders', () => {
|
it('identifies only task-backed views as task data loaders', () => {
|
||||||
expect(['tasks', 'today', 'upcoming'].filter(isTaskView)).toEqual(['tasks', 'today', 'upcoming'])
|
expect(['tasks', 'today', 'upcoming'].filter(isTaskView)).toEqual(['tasks', 'today', 'upcoming'])
|
||||||
expect(['calendar', 'habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
|
expect(['habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only completes numeric habits when progress reaches the target', () => {
|
||||||
|
expect(isHabitComplete('numeric', 3, 5)).toBe(false)
|
||||||
|
expect(isHabitComplete('numeric', 5, 5)).toBe(true)
|
||||||
|
expect(isHabitComplete('boolean', 1, 5)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not invent a numeric value when the input is empty', () => {
|
||||||
|
expect(numericHabitInputValue(undefined)).toBeNull()
|
||||||
|
expect(numericHabitInputValue('')).toBeNull()
|
||||||
|
expect(numericHabitInputValue(4)).toBe(4)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,26 +5,6 @@ export function dateKey(date: Date) {
|
|||||||
return `${y}-${m}-${d}`
|
return `${y}-${m}-${d}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calendarRange(date: Date) {
|
|
||||||
const first = new Date(date.getFullYear(), date.getMonth(), 1)
|
|
||||||
const from = new Date(first)
|
|
||||||
const mondayOffset = (first.getDay() + 6) % 7
|
|
||||||
from.setDate(first.getDate() - mondayOffset)
|
|
||||||
const to = new Date(from)
|
|
||||||
to.setDate(from.getDate() + 41)
|
|
||||||
return { from: dateKey(from), to: dateKey(to) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveDueDate(current: string | null, day: string) {
|
|
||||||
const source = current ? new Date(current) : null
|
|
||||||
const hours = source && !Number.isNaN(source.valueOf()) ? source.getHours() : 9
|
|
||||||
const minutes = source && !Number.isNaN(source.valueOf()) ? source.getMinutes() : 0
|
|
||||||
const date = new Date(`${day}T00:00:00`)
|
|
||||||
date.setHours(hours, minutes, 0, 0)
|
|
||||||
const offsetMs = date.getTimezoneOffset() * 60_000
|
|
||||||
return new Date(date.getTime() - offsetMs).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function habitWeek(now = new Date()) {
|
export function habitWeek(now = new Date()) {
|
||||||
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
|
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
|
||||||
@@ -43,3 +23,14 @@ export function mergePage<T>(page: T[] | { items?: T[]; next_cursor?: string | n
|
|||||||
export function isTaskView(view: string) {
|
export function isTaskView(view: string) {
|
||||||
return view === 'tasks' || view === 'today' || view === 'upcoming'
|
return view === 'tasks' || view === 'today' || view === 'upcoming'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isHabitComplete(kind: string | undefined, value: number | boolean | undefined, target = 1) {
|
||||||
|
if (kind === 'numeric') return Number(value ?? 0) >= target
|
||||||
|
return Boolean(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numericHabitInputValue(input: number | string | undefined) {
|
||||||
|
if (input === undefined || input === '') return null
|
||||||
|
const value = Number(input)
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : null
|
||||||
|
}
|
||||||
|
|||||||
+18
-2
@@ -9,7 +9,23 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
|
|||||||
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||||
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
||||||
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||||
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.calendar-card,.habit-card,.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.fc{--fc-button-bg-color:var(--accent);--fc-button-border-color:var(--accent);--fc-button-hover-bg-color:#cf461d;--fc-today-bg-color:#fff3eb;font-size:13px}.fc .fc-toolbar-title{font-size:18px}.fc .fc-event{border-color:var(--accent);background:var(--accent);cursor:grab}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.habit-title{display:flex;justify-content:space-between;align-items:start}.habit-title h3,.tool-card h3{margin:0 0 4px}.habit-title small{color:var(--muted)}.week-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;margin-top:13px}.week-grid>div{display:grid;place-items:center;gap:5px;text-align:center}.week-grid small{color:var(--muted);font-size:10px}.habit-check{width:34px;height:34px;border-radius:50%;border:1px solid var(--line);background:#faf7f0}.habit-check.done{background:var(--accent);border-color:var(--accent);color:#fff}.week-grid input{width:100%;min-width:0;border:1px solid var(--line);border-radius:7px;padding:7px;text-align:center}.empty-panel{text-align:center;color:var(--muted)}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
|
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px}
|
||||||
@media(max-width:800px){.habit-create{grid-template-columns:1fr 1fr}.habit-create button{justify-content:center}.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.fc .fc-toolbar{align-items:flex-start}.fc .fc-toolbar-title{font-size:16px}.calendar-card{padding:8px}.week-grid{gap:3px}.habit-card{padding:12px}.habit-check{width:30px;height:30px}}
|
|
||||||
|
.habit-row{display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:5px 5px 5px 12px;box-shadow:0 3px 14px rgba(81,61,38,.05);transition:border-color .15s,box-shadow .15s}
|
||||||
|
.habit-row.done{border-color:var(--accent)}
|
||||||
|
.habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px}
|
||||||
|
.habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.habit-main>span:first-child{display:grid;gap:3px}.habit-main small{font-size:11px;color:var(--muted)}
|
||||||
|
.habit-row.done .habit-name{color:var(--accent)}
|
||||||
|
.habit-check-button{width:44px;height:44px;flex:0 0 44px;border-radius:50%;border:1.5px solid #cfc3b3;background:#fff;color:#fff;display:grid;place-items:center;padding:0}
|
||||||
|
.habit-check-button.done{background:var(--accent);border-color:var(--accent)}.habit-check-button svg{width:19px;height:19px;stroke-width:2.6}
|
||||||
|
.habit-row .icon.ghost{color:#b3a795;margin-left:4px}
|
||||||
|
.habit-row .icon.ghost:hover{color:var(--danger)}
|
||||||
|
.numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)}
|
||||||
|
.numeric-action{display:flex;gap:6px;align-items:center}.numeric-action input{width:74px;border:1px solid var(--line);border-radius:8px;padding:7px;background:#fff}.numeric-action .soft-button{min-height:44px;padding:9px 12px}
|
||||||
|
.habit-row>.icon.ghost{width:44px;height:44px;flex:0 0 44px}
|
||||||
|
.empty-panel{text-align:center;color:var(--muted)}
|
||||||
|
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
|
||||||
|
@media(max-width:800px){.habit-create{grid-template-columns:1fr 1fr}.habit-create button{justify-content:center}.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-row{padding:5px 4px 5px 8px}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
|
||||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
||||||
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}
|
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}
|
||||||
|
|||||||
+7
-195
@@ -29,160 +29,12 @@ def test_bootstrap_returns_navigation_and_current_user(client):
|
|||||||
assert data["inbox_id"] == inbox["id"]
|
assert data["inbox_id"] == inbox["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_recurring_calendar_exceptions_and_scopes(client):
|
def test_calendar_endpoint_is_removed(client):
|
||||||
inbox = boot(client)
|
|
||||||
task = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "站会", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"},
|
|
||||||
).json()
|
|
||||||
normal_task = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "月内普通任务", "list_id": inbox["id"], "due_at": "2026-09-12T09:00:00Z"},
|
|
||||||
).json()
|
|
||||||
client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "月外普通任务", "list_id": inbox["id"], "due_at": "2026-10-02T09:00:00Z"},
|
|
||||||
)
|
|
||||||
recurrence = client.post(
|
|
||||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=5"}
|
|
||||||
)
|
|
||||||
assert recurrence.status_code == 201
|
|
||||||
recurrence_id = recurrence.json()["id"]
|
|
||||||
yearly = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "年度任务", "list_id": inbox["id"], "due_at": "2026-09-06T09:00:00Z"},
|
|
||||||
).json()
|
|
||||||
assert client.post(
|
|
||||||
"/api/v1/recurrences", json={"task_id": yearly["id"], "rrule": "FREQ=YEARLY;INTERVAL=1;BYMONTH=9;BYMONTHDAY=6"}
|
|
||||||
).status_code == 201
|
|
||||||
calendar = client.get(
|
|
||||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
|
||||||
).json()
|
|
||||||
assert any(row.get("id") == normal_task["id"] for row in calendar)
|
|
||||||
assert any(row["title"] == "年度任务" for row in calendar)
|
|
||||||
assert not any(row["title"] == "月外普通任务" for row in calendar)
|
|
||||||
occurrences = [row for row in calendar if row["recurrence_id"] == recurrence_id]
|
|
||||||
assert len(occurrences) == 5
|
|
||||||
assert occurrences[0]["title"] == "站会"
|
|
||||||
|
|
||||||
at = occurrences[1]["occurrence_at"]
|
|
||||||
edited = client.patch(
|
|
||||||
f"/api/v1/recurrences/{recurrence_id}",
|
|
||||||
params={"scope": "this", "occurrence_at": at},
|
|
||||||
json={"title": "特殊站会"},
|
|
||||||
)
|
|
||||||
assert edited.status_code == 200
|
|
||||||
client.post(f"/api/v1/recurrences/{recurrence_id}/complete", json={"occurrence_at": at})
|
|
||||||
changed = client.get(
|
|
||||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
|
||||||
).json()
|
|
||||||
exception = next(row for row in changed if row.get("occurrence_at") == at)
|
|
||||||
assert exception["title"] == "特殊站会" and exception["completed"] is True
|
|
||||||
assert client.delete(
|
|
||||||
f"/api/v1/recurrences/{recurrence_id}",
|
|
||||||
params={"scope": "this", "occurrence_at": occurrences[2]["occurrence_at"]},
|
|
||||||
).status_code == 204
|
|
||||||
remaining = client.get(
|
|
||||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
|
||||||
).json()
|
|
||||||
assert len([row for row in remaining if row["recurrence_id"] == recurrence_id]) == 4
|
|
||||||
assert any(row.get("id") == normal_task["id"] for row in remaining)
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_respects_timezone_boundaries_and_moved_occurrences(client):
|
|
||||||
inbox = boot(client)
|
|
||||||
inside = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "上海九月第一刻", "list_id": inbox["id"], "due_at": "2026-08-31T16:30:00Z"},
|
|
||||||
).json()
|
|
||||||
client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "上海十月第一刻", "list_id": inbox["id"], "due_at": "2026-09-30T16:30:00Z"},
|
|
||||||
)
|
|
||||||
recurring = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "跨月重复任务", "list_id": inbox["id"], "due_at": "2026-10-01T09:00:00Z"},
|
|
||||||
).json()
|
|
||||||
recurrence = client.post(
|
|
||||||
"/api/v1/recurrences", json={"task_id": recurring["id"], "rrule": "FREQ=WEEKLY;COUNT=2"}
|
|
||||||
).json()
|
|
||||||
client.patch(
|
|
||||||
f"/api/v1/recurrences/{recurrence['id']}",
|
|
||||||
params={"scope": "this", "occurrence_at": "2026-10-01T09:00:00Z"},
|
|
||||||
json={"due_at": "2026-09-20T09:00:00Z"},
|
|
||||||
)
|
|
||||||
|
|
||||||
calendar = client.get(
|
|
||||||
"/api/v1/calendar",
|
|
||||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Asia/Shanghai"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
assert any(row.get("id") == inside["id"] for row in calendar)
|
|
||||||
assert not any(row["title"] == "上海十月第一刻" for row in calendar)
|
|
||||||
assert any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_rejects_unknown_timezone(client):
|
|
||||||
boot(client)
|
boot(client)
|
||||||
response = client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Mars/Olympus"})
|
assert client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_recurrence_mutations_reject_ghost_occurrences(client):
|
def test_recurrence_mutations_keep_exact_timestamp_validation(client):
|
||||||
inbox = boot(client)
|
|
||||||
task = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "每周任务", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
|
|
||||||
).json()
|
|
||||||
recurrence = client.post(
|
|
||||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;COUNT=3"}
|
|
||||||
).json()
|
|
||||||
# 9/7 and 9/14 are real occurrences; 9/9 is NOT part of this series.
|
|
||||||
ghost_patch = client.patch(
|
|
||||||
f"/api/v1/recurrences/{recurrence['id']}",
|
|
||||||
params={"scope": "this", "occurrence_at": "2026-09-09T09:00:00Z"},
|
|
||||||
json={"due_at": "2026-09-20T09:00:00Z"},
|
|
||||||
)
|
|
||||||
assert ghost_patch.status_code == 422
|
|
||||||
|
|
||||||
calendar = client.get(
|
|
||||||
"/api/v1/calendar",
|
|
||||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
assert not any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
|
|
||||||
assert len([row for row in calendar if row.get("recurrence_id") == recurrence["id"]]) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_does_not_duplicate_moved_exceptions_when_both_in_range(client):
|
|
||||||
inbox = boot(client)
|
|
||||||
task = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
|
|
||||||
).json()
|
|
||||||
recurrence = client.post(
|
|
||||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=4"}
|
|
||||||
).json()
|
|
||||||
# Series: 9/14, 9/21, 9/28, 10/5. Move the 9/21 occurrence to 9/23.
|
|
||||||
client.patch(
|
|
||||||
f"/api/v1/recurrences/{recurrence['id']}",
|
|
||||||
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
|
|
||||||
json={"due_at": "2026-09-23T09:00:00Z"},
|
|
||||||
)
|
|
||||||
calendar = client.get(
|
|
||||||
"/api/v1/calendar",
|
|
||||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
|
|
||||||
).json()
|
|
||||||
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
|
|
||||||
keys = [(row["occurrence_at"], row["due_at"]) for row in matches]
|
|
||||||
assert len(keys) == len(set(keys))
|
|
||||||
assert len(matches) == 3
|
|
||||||
due_days = [row["due_at"][:10] for row in matches]
|
|
||||||
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
|
|
||||||
assert not any(row["due_at"].startswith("2026-09-21") for row in matches)
|
|
||||||
|
|
||||||
|
|
||||||
def test_recurrence_rejects_wrong_time_of_day_and_alternate_offset(client):
|
|
||||||
inbox = boot(client)
|
inbox = boot(client)
|
||||||
task = client.post(
|
task = client.post(
|
||||||
"/api/v1/tasks",
|
"/api/v1/tasks",
|
||||||
@@ -216,56 +68,16 @@ def test_recurrence_rejects_occurrence_after_cutoff(client):
|
|||||||
recurrence = client.post(
|
recurrence = client.post(
|
||||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY;COUNT=10"}
|
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY;COUNT=10"}
|
||||||
).json()
|
).json()
|
||||||
# Cut the series off at 9/3 (ends_at = 9/3T08:59:59.999999Z)
|
assert client.patch(
|
||||||
client.patch(
|
|
||||||
f"/api/v1/recurrences/{recurrence['id']}",
|
f"/api/v1/recurrences/{recurrence['id']}",
|
||||||
params={"scope": "this-and-future", "occurrence_at": "2026-09-03T09:00:00Z"},
|
params={"scope": "this-and-future", "occurrence_at": "2026-09-03T09:00:00Z"},
|
||||||
json={},
|
json={},
|
||||||
)
|
).status_code == 200
|
||||||
after_cutoff = client.patch(
|
assert client.patch(
|
||||||
f"/api/v1/recurrences/{recurrence['id']}",
|
f"/api/v1/recurrences/{recurrence['id']}",
|
||||||
params={"scope": "this", "occurrence_at": "2026-09-05T09:00:00Z"},
|
params={"scope": "this", "occurrence_at": "2026-09-05T09:00:00Z"},
|
||||||
json={"title": "晚于截止"},
|
json={"title": "晚于截止"},
|
||||||
)
|
).status_code == 422
|
||||||
assert after_cutoff.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_accepts_legacy_timezone_offset(client):
|
|
||||||
inbox = boot(client)
|
|
||||||
client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "上海边界任务", "list_id": inbox["id"], "due_at": "2026-09-01T16:00:00Z"},
|
|
||||||
)
|
|
||||||
calendar = client.get(
|
|
||||||
"/api/v1/calendar",
|
|
||||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone_offset": 480},
|
|
||||||
).json()
|
|
||||||
assert any(row["title"] == "上海边界任务" for row in calendar)
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_does_not_duplicate_moved_exception_when_original_and_moved_in_range(client):
|
|
||||||
inbox = boot(client)
|
|
||||||
task = client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
|
|
||||||
).json()
|
|
||||||
recurrence = client.post(
|
|
||||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=3"}
|
|
||||||
).json()
|
|
||||||
# Series: 9/14, 9/21, 9/28 — all inside the requested month.
|
|
||||||
# Move the 9/21 occurrence to 9/23 (also inside). Regression: this used to render twice.
|
|
||||||
client.patch(
|
|
||||||
f"/api/v1/recurrences/{recurrence['id']}",
|
|
||||||
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
|
|
||||||
json={"due_at": "2026-09-23T09:00:00Z"},
|
|
||||||
)
|
|
||||||
calendar = client.get(
|
|
||||||
"/api/v1/calendar",
|
|
||||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
|
|
||||||
).json()
|
|
||||||
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
|
|
||||||
due_days = sorted(row["due_at"][:10] for row in matches)
|
|
||||||
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_habit_logs_support_date_range_filter(client):
|
def test_habit_logs_support_date_range_filter(client):
|
||||||
|
|||||||
Reference in New Issue
Block a user