fix: calendar recurrence tz/overrides, today habits schedule, list archive mobile access
ci / docker (push) Successful in 3m23s

This commit is contained in:
2026-09-06 11:33:21 +08:00
parent 0cc486069e
commit 4e888ffe1c
10 changed files with 166 additions and 20 deletions
+33 -11
View File
@@ -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
def _as_utc(value, default_tz=LOCAL_TZ):
def _localized_ics_datetime(value, default_tz=LOCAL_TZ):
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=default_tz)
return value.astimezone(UTC), False
return value if value.tzinfo else value.replace(tzinfo=default_tz), False
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")
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):
if event.get("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):
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 = []
for event in calendar.walk("VEVENT"):
if not event.get("dtstart"):
for event in components:
if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED":
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)
excluded = _excluded_starts(event)
uid = str(event.get("uid") or "")
if event.get("rrule") and window_start and window_end:
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):
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=UTC)
if occurrence in excluded:
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=LOCAL_TZ).astimezone(UTC)
if occurrence in excluded or (uid, occurrence) in overrides:
continue
ends_at = occurrence + duration
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
if _overlaps(starts_at, ends_at, window_start, window_end):
_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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
<link rel="stylesheet" crossorigin href="/assets/index-CDrLYTXE.css">
<!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-BBuUKFZL.css">
</head><body><div id="app"></div></body></html>
+1 -1
View File
@@ -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>
<template v-if="archivedLists.length">
<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>
</div>
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
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'
type View = 'habits' | 'today-habits' | 'settings'
@@ -29,7 +29,7 @@ const restoreFile = ref<File | null>(null)
const todayKey = ref(dateKey(new Date()))
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
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
function formatErrorMessage(detail: unknown): string {
+10 -1
View File
@@ -1,5 +1,5 @@
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', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -33,6 +33,15 @@ describe('MVP view utilities', () => {
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', () => {
expect(nextHabitSwipeValue('numeric', 2, 5)).toBe(3)
expect(nextHabitSwipeValue('numeric', 5, 5)).toBe(5)
+10
View File
@@ -33,6 +33,16 @@ export function isHabitComplete(kind: string | undefined, value: number | boolea
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) {
const value = Number(current ?? 0)
if (kind === 'numeric') return Math.min(value + 1, target)
+1 -1
View File
@@ -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}
.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: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}
.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}
+105
View File
@@ -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):
from fastapi import HTTPException