fix: calendar recurrence tz/overrides, today habits schedule, list archive mobile access
ci / docker (push) Successful in 3m23s
ci / docker (push) Successful in 3m23s
This commit is contained in:
+33
-11
@@ -280,16 +280,19 @@ def _is_forbidden_ip(value: str) -> bool:
|
|||||||
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved
|
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved
|
||||||
|
|
||||||
|
|
||||||
def _as_utc(value, default_tz=LOCAL_TZ):
|
def _localized_ics_datetime(value, default_tz=LOCAL_TZ):
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
if value.tzinfo is None:
|
return value if value.tzinfo else value.replace(tzinfo=default_tz), False
|
||||||
value = value.replace(tzinfo=default_tz)
|
|
||||||
return value.astimezone(UTC), False
|
|
||||||
if isinstance(value, date):
|
if isinstance(value, date):
|
||||||
return datetime.combine(value, time.min, tzinfo=default_tz).astimezone(UTC), True
|
return datetime.combine(value, time.min, tzinfo=default_tz), True
|
||||||
raise ValueError("unsupported ICS datetime")
|
raise ValueError("unsupported ICS datetime")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value, default_tz=LOCAL_TZ):
|
||||||
|
localized, all_day = _localized_ics_datetime(value, default_tz)
|
||||||
|
return localized.astimezone(UTC), all_day
|
||||||
|
|
||||||
|
|
||||||
def _event_duration(event, starts_at: datetime, all_day: bool):
|
def _event_duration(event, starts_at: datetime, all_day: bool):
|
||||||
if event.get("dtend"):
|
if event.get("dtend"):
|
||||||
ends_at, _ = _as_utc(event.decoded("dtend"))
|
ends_at, _ = _as_utc(event.decoded("dtend"))
|
||||||
@@ -334,19 +337,28 @@ def _overlaps(starts_at: datetime, ends_at: datetime | None, window_start: datet
|
|||||||
|
|
||||||
def parse_ics_events(content: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
|
def parse_ics_events(content: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
|
||||||
calendar = Calendar.from_ical(content)
|
calendar = Calendar.from_ical(content)
|
||||||
|
components = list(calendar.walk("VEVENT"))
|
||||||
|
overrides = {}
|
||||||
|
for event in components:
|
||||||
|
recurrence_id = event.get("recurrence-id")
|
||||||
|
if recurrence_id:
|
||||||
|
overrides[(str(event.get("uid") or ""), _as_utc(event.decoded("recurrence-id"))[0])] = event
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
for event in calendar.walk("VEVENT"):
|
for event in components:
|
||||||
if not event.get("dtstart"):
|
if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED":
|
||||||
continue
|
continue
|
||||||
starts_at, all_day = _as_utc(event.decoded("dtstart"))
|
localized_start, all_day = _localized_ics_datetime(event.decoded("dtstart"))
|
||||||
|
starts_at = localized_start.astimezone(UTC)
|
||||||
duration = _event_duration(event, starts_at, all_day)
|
duration = _event_duration(event, starts_at, all_day)
|
||||||
excluded = _excluded_starts(event)
|
excluded = _excluded_starts(event)
|
||||||
|
uid = str(event.get("uid") or "")
|
||||||
if event.get("rrule") and window_start and window_end:
|
if event.get("rrule") and window_start and window_end:
|
||||||
rule_text = event.get("rrule").to_ical().decode()
|
rule_text = event.get("rrule").to_ical().decode()
|
||||||
rule = rrulestr(rule_text, dtstart=starts_at)
|
rule = rrulestr(rule_text, dtstart=localized_start)
|
||||||
for occurrence in rule.between(window_start - duration, window_end, inc=True):
|
for occurrence in rule.between(window_start - duration, window_end, inc=True):
|
||||||
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=UTC)
|
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=LOCAL_TZ).astimezone(UTC)
|
||||||
if occurrence in excluded:
|
if occurrence in excluded or (uid, occurrence) in overrides:
|
||||||
continue
|
continue
|
||||||
ends_at = occurrence + duration
|
ends_at = occurrence + duration
|
||||||
if _overlaps(occurrence, ends_at, window_start, window_end):
|
if _overlaps(occurrence, ends_at, window_start, window_end):
|
||||||
@@ -355,6 +367,16 @@ def parse_ics_events(content: str, source_name: str, color: str, window_start: d
|
|||||||
ends_at = starts_at + duration
|
ends_at = starts_at + duration
|
||||||
if _overlaps(starts_at, ends_at, window_start, window_end):
|
if _overlaps(starts_at, ends_at, window_start, window_end):
|
||||||
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
|
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
|
||||||
|
|
||||||
|
for event in overrides.values():
|
||||||
|
if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED":
|
||||||
|
continue
|
||||||
|
starts_at, all_day = _as_utc(event.decoded("dtstart"))
|
||||||
|
duration = _event_duration(event, starts_at, all_day)
|
||||||
|
ends_at = starts_at + duration
|
||||||
|
if _overlaps(starts_at, ends_at, window_start, window_end):
|
||||||
|
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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-BhF1FT2t.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-BqVyvsa4.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CDrLYTXE.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BBuUKFZL.css">
|
||||||
</head><body><div id="app"></div></body></html>
|
</head><body><div id="app"></div></body></html>
|
||||||
@@ -420,7 +420,7 @@ onMounted(bootstrap)
|
|||||||
<button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click.stop="deleteEntity('lists',list)"><ArchiveRestore/></button></span></button>
|
<button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click.stop="deleteEntity('lists',list)"><ArchiveRestore/></button></span></button>
|
||||||
<template v-if="archivedLists.length">
|
<template v-if="archivedLists.length">
|
||||||
<div class="section-title"><span>已归档清单</span></div>
|
<div class="section-title"><span>已归档清单</span></div>
|
||||||
<button v-for="list in archivedLists" :key="list.id" class="list-row archived-row" @click="restoreList(list)"><ArchiveRestore/><span>{{list.name}}</span><span class="row-actions"><button aria-label="恢复清单" @click.stop="restoreList(list)"><ArchiveRestore/></button></span></button>
|
<button v-for="list in archivedLists" :key="list.id" class="list-row archived-row" @click="restoreList(list)"><ArchiveRestore/><span>{{list.name}}</span><span class="row-actions"><button aria-label="恢复清单" @click.stop="restoreList(list)"><ArchiveRestore/>恢复</button></span></button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
||||||
import { dateKey, isHabitComplete, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './lib/mvp-utils'
|
import { dateKey, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
type View = 'habits' | 'today-habits' | 'settings'
|
||||||
@@ -29,7 +29,7 @@ const restoreFile = ref<File | null>(null)
|
|||||||
const todayKey = ref(dateKey(new Date()))
|
const todayKey = ref(dateKey(new Date()))
|
||||||
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||||
const habitSwipeOffsets = ref<Record<string, number>>({})
|
const habitSwipeOffsets = ref<Record<string, number>>({})
|
||||||
const todayHabits = computed(() => habits.value.filter((item) => item.kind === 'boolean' || (item.cells ?? []).some((cell) => cell.day === todayKey.value && cell.scheduled)))
|
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
||||||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
function formatErrorMessage(detail: unknown): string {
|
function formatErrorMessage(detail: unknown): string {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { dateKey, defaultView, habitWeek, isHabitComplete, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './mvp-utils'
|
import { dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './mvp-utils'
|
||||||
|
|
||||||
describe('MVP view utilities', () => {
|
describe('MVP view utilities', () => {
|
||||||
it('formats a local date as YYYY-MM-DD', () => {
|
it('formats a local date as YYYY-MM-DD', () => {
|
||||||
@@ -33,6 +33,15 @@ describe('MVP view utilities', () => {
|
|||||||
expect(defaultView()).toBe('today')
|
expect(defaultView()).toBe('today')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('only includes scheduled, unpaused habits today regardless of kind or completion', () => {
|
||||||
|
const day = '2026-09-06'
|
||||||
|
expect(isHabitScheduledToday({ kind: 'boolean', cells: [{ day, scheduled: true, paused: false, value: true }] }, day)).toBe(true)
|
||||||
|
expect(isHabitScheduledToday({ kind: 'numeric', cells: [{ day, scheduled: true, paused: false, value: 5 }] }, day)).toBe(true)
|
||||||
|
expect(isHabitScheduledToday({ kind: 'boolean', cells: [{ day, scheduled: false, paused: false, value: true }] }, day)).toBe(false)
|
||||||
|
expect(isHabitScheduledToday({ kind: 'numeric', cells: [{ day, scheduled: true, paused: true, value: 0 }] }, day)).toBe(false)
|
||||||
|
expect(isHabitScheduledToday({ kind: 'boolean', cells: [] }, day)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('increments count habits one swipe at a time and caps at target', () => {
|
it('increments count habits one swipe at a time and caps at target', () => {
|
||||||
expect(nextHabitSwipeValue('numeric', 2, 5)).toBe(3)
|
expect(nextHabitSwipeValue('numeric', 2, 5)).toBe(3)
|
||||||
expect(nextHabitSwipeValue('numeric', 5, 5)).toBe(5)
|
expect(nextHabitSwipeValue('numeric', 5, 5)).toBe(5)
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ export function isHabitComplete(kind: string | undefined, value: number | boolea
|
|||||||
return Boolean(value)
|
return Boolean(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HabitSchedule = {
|
||||||
|
kind?: string
|
||||||
|
cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHabitScheduledToday(habit: HabitSchedule, day: string) {
|
||||||
|
const cell = (habit.cells ?? []).find((item) => item.day === day)
|
||||||
|
return Boolean(cell?.scheduled && !cell.paused)
|
||||||
|
}
|
||||||
|
|
||||||
export function nextHabitSwipeValue(kind: string | undefined, current: number | boolean | undefined, target = 1) {
|
export function nextHabitSwipeValue(kind: string | undefined, current: number | boolean | undefined, target = 1) {
|
||||||
const value = Number(current ?? 0)
|
const value = Number(current ?? 0)
|
||||||
if (kind === 'numeric') return Math.min(value + 1, target)
|
if (kind === 'numeric') return Math.min(value + 1, target)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
|
|||||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||||
.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}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row>svg{color:#c9a45c}.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}.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}
|
.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}
|
||||||
|
|
||||||
.habit-row{--swipe-x:0px;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,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}
|
.habit-row{--swipe-x:0px;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,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}
|
||||||
|
|||||||
@@ -70,6 +70,111 @@ END:VCALENDAR\r
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_parser_keeps_recurrence_in_wall_clock_timezone_across_dst():
|
||||||
|
from backend.mvp import parse_ics_events
|
||||||
|
|
||||||
|
ics = """BEGIN:VCALENDAR\r
|
||||||
|
VERSION:2.0\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:dst-recurring\r
|
||||||
|
DTSTART;TZID=America/New_York:20260307T090000\r
|
||||||
|
DTEND;TZID=America/New_York:20260307T100000\r
|
||||||
|
RRULE:FREQ=DAILY;COUNT=3\r
|
||||||
|
SUMMARY:Morning meeting\r
|
||||||
|
END:VEVENT\r
|
||||||
|
END:VCALENDAR\r
|
||||||
|
"""
|
||||||
|
|
||||||
|
events = parse_ics_events(
|
||||||
|
ics,
|
||||||
|
"DST calendar",
|
||||||
|
"#fff",
|
||||||
|
datetime(2026, 3, 7, tzinfo=UTC),
|
||||||
|
datetime(2026, 3, 11, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [row["starts_at"].isoformat() for row in events] == [
|
||||||
|
"2026-03-07T14:00:00+00:00",
|
||||||
|
"2026-03-08T13:00:00+00:00",
|
||||||
|
"2026-03-09T13:00:00+00:00",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_parser_replaces_master_occurrence_with_override():
|
||||||
|
from backend.mvp import parse_ics_events
|
||||||
|
|
||||||
|
ics = """BEGIN:VCALENDAR\r
|
||||||
|
VERSION:2.0\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:overridden-series\r
|
||||||
|
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
|
||||||
|
DTEND;TZID=Asia/Shanghai:20260906T100000\r
|
||||||
|
RRULE:FREQ=DAILY;COUNT=2\r
|
||||||
|
SUMMARY:原始事件\r
|
||||||
|
END:VEVENT\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:overridden-series\r
|
||||||
|
RECURRENCE-ID;TZID=Asia/Shanghai:20260907T090000\r
|
||||||
|
DTSTART;TZID=Asia/Shanghai:20260907T110000\r
|
||||||
|
DTEND;TZID=Asia/Shanghai:20260907T120000\r
|
||||||
|
SUMMARY:改期事件\r
|
||||||
|
END:VEVENT\r
|
||||||
|
END:VCALENDAR\r
|
||||||
|
"""
|
||||||
|
|
||||||
|
events = parse_ics_events(
|
||||||
|
ics,
|
||||||
|
"测试日历",
|
||||||
|
"#fff",
|
||||||
|
datetime(2026, 9, 5, tzinfo=UTC),
|
||||||
|
datetime(2026, 9, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
|
||||||
|
("原始事件", "2026-09-06T01:00:00+00:00"),
|
||||||
|
("改期事件", "2026-09-07T03:00:00+00:00"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_parser_skips_cancelled_events_and_cancelled_overrides():
|
||||||
|
from backend.mvp import parse_ics_events
|
||||||
|
|
||||||
|
ics = """BEGIN:VCALENDAR\r
|
||||||
|
VERSION:2.0\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:cancelled-single\r
|
||||||
|
DTSTART:20260906T090000\r
|
||||||
|
STATUS:CANCELLED\r
|
||||||
|
SUMMARY:已取消单次事件\r
|
||||||
|
END:VEVENT\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:partly-cancelled-series\r
|
||||||
|
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
|
||||||
|
RRULE:FREQ=DAILY;COUNT=2\r
|
||||||
|
SUMMARY:重复事件\r
|
||||||
|
END:VEVENT\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:partly-cancelled-series\r
|
||||||
|
RECURRENCE-ID;TZID=Asia/Shanghai:20260907T090000\r
|
||||||
|
STATUS:CANCELLED\r
|
||||||
|
SUMMARY:已取消实例\r
|
||||||
|
END:VEVENT\r
|
||||||
|
END:VCALENDAR\r
|
||||||
|
"""
|
||||||
|
|
||||||
|
events = parse_ics_events(
|
||||||
|
ics,
|
||||||
|
"测试日历",
|
||||||
|
"#fff",
|
||||||
|
datetime(2026, 9, 5, tzinfo=UTC),
|
||||||
|
datetime(2026, 9, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
|
||||||
|
("重复事件", "2026-09-06T01:00:00+00:00"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_url_rejects_private_addresses(monkeypatch):
|
def test_calendar_url_rejects_private_addresses(monkeypatch):
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user