OpenMontage:用时间块与规则引擎拼接多源信息的统一日程管理思路

发布时间:2026/10/27 20:29:16
OpenMontage:用时间块与规则引擎拼接多源信息的统一日程管理思路

1. 为什么我会想做一个叫 OpenMontage 的东西

先说个背景。我平时的工作流里,日程、任务、笔记、项目看板、甚至订阅的资讯摘要,散落在至少五六个不同的工具里。日历管时间、Trello 管任务、Notion 管文档、Feedly 管信息流,每个工具单独看都挺好,但一旦想回答“我这周到底要干什么”“下个月有哪些事会撞在一起”这种综合性问题,就得来回切换、手动比对,特别费劲。

我一度想过用现成的聚合工具,比如那种把 GitHub 事件、Google 日历、待办清单全部拉到一个时间轴上的应用。试了几个之后发现两个问题:第一,它们大多只做“展示层聚合”,就是把你已有的数据拉过来排一排,但数据之间的关联关系、优先级、阻断逻辑完全没有;第二,扩展性不够,想接一个冷门的内部系统或者自定义数据源,基本要靠写死插件,维护成本高。

后来我就在想,能不能自己做一个“开放式的蒙太奇”—— Montage 这个词本来是电影剪辑里的术语,指把不同镜头拼接在一起,形成一个新的叙事整体。我想要的工具也是这样:把不同来源的信息碎片拼接到一个统一的时间轴上,但拼接规则可以由我自己定义,而不是被工具厂商预设好的逻辑绑死。这个想法就是 OpenMontage 的起点,名字里的 Open 指两件事:一是数据源开放接入,二是拼接规则开放可配。

这篇文章就完整记录一下我从零开始做这个小项目的核心思路、数据模型设计、聚合视图层实现,以及我在实际开发中踩过的几个比较典型的坑。如果你也有类似的“多源信息拼接”需求,不管你是打算自己写一个,还是只是想找一种更灵活的思路来组织个人工作流,这篇应该都能给你一些参考。

2. 先把核心问题拆清楚:拼接什么、怎么拼接、为什么这么拼接

动手写代码之前,我花了相当多的时间做需求拆解。OpenMontage 不是一个单纯的日历应用,也不是一个任务管理软件,它的核心能力是“拼接”。所以我先想清楚拼接的三要素:对象、规则、呈现方式。

2.1 拼接对象:不止是日程和任务,而是“时间块”

传统工具里,日程(event)和任务(task)是两种截然不同的实体,日历只管时间点或时间段,任务清单只管“待办”状态。但实际生活中这俩的边界是模糊的:一个“下午写周报”的事情,既是一个时间块,也是一个待办项;一个“本周内完成客户方案”的任务,虽然没有固定时间,但它会挤占某些天的专注时段。

所以我在 OpenMontage 里引入了一个统一概念叫TimeBlock(时间块)。它不区分你是日程还是任务,只表达一件事:某个对象在某个时间维度上占据了一段注意力。这个设计的收益是在聚合层不用再写两套合并逻辑,所有来源的数据最终都会归一化成 TimeBlock,后续做冲突检测、优先级排序、视图渲染只用面对一个模型。

每个 TimeBlock 的核心字段我设计为:source(来源标识)、title(标题)、start_at/end_at(起止时间,允许为空表示无固定时间)、kind(类型,如 meeting/session/reminder/reference)、weight(权重,0 到 1,表示这个块对自己注意力的占用程度)、tags(标签数组)、raw(原始数据 JSON 留存,方便追溯)。这个模型很像日历事件,但多了一个weight字段,这是后面做聚合排序时的关键变量。

2.2 拼接规则:优先级、窗口、阻断逻辑

数据拉进来之后,最核心的问题是:两个时间块重叠了,谁显示在上层?任务没有明确时间,怎么排进时间轴?这些都属于拼接规则。我把规则拆成了三层:

第一层是优先级规则,每个数据源可以设置一个基础优先级,比如工作日历 90 分、个人提醒 60 分、资讯摘要 20 分。当两个块时间重叠时,分数高的显示为主体,分数低的折叠到“次要点”区域。

第二层是窗口规则,也就是“没有明确时间的任务怎么放进时间轴”。我的做法不是随机塞,而是通过weight和任务的最迟截止时间做反向推算。假设一个任务需要 4 小时专注时间,截止时间是周五 18:00,那系统就会从周四下午开始向前找空闲窗口,找到连续 4 小时的空档就自动放置,并标记为“建议安排”,而不是硬性占用。

第三层是阻断规则,比如“每周三下午开周会”这种周期性事件,以及“上午 10 点到 12 点不要安排深度工作”这种个人节律设置,都会作为硬约束参与到聚合计算里。这一层是最容易被忽略的,但恰恰是让时间轴“看起来合理”的关键,没有阻断规则,你排出来的时间轴就会不断出现“下午 3 点刚开完会又马上安排深度工作”这种反人性的安排。

2.3 拼接结果的呈现:单轴 vs 多轴、密度感知

有了统一的 TimeBlock 模型和规则层之后,呈现层其实就简单了很多。OpenMontage 默认提供两种视图:一个是单轴时间线,所有来源的数据按时间排列在一条轴上,来源之间用颜色区分;另一个是密度热力周视图,把一周按小时切成格子,每个格子的颜色深浅代表这个时段被占用的程度,方便快速发现过度拥挤的日子。

单轴视图我刻意做成类似视频剪辑软件里的轨道,每个数据源是一条轨道(Track),时间块是轨道上的片段(Clip),选中任意片段可以下钻查看原始数据。这个设计灵感就来自蒙太奇的剪辑台,不同的轨道代表不同的信息源,你要做的就是把它们对齐、剪辑、形成自己一周的“叙事线”。说实话,用这个比喻之后,连我自己对项目的理解都清晰了很多,底层结构其实就是“轨道 + 片段”,跟剪辑软件如出一辙。

3. 数据模型与存储设计:归一化是聚合工具的命门

前面提到 TimeBlock 是统一模型,但实际落地时你会发现,数据的归一化远比想象中复杂。我最初天真地以为写几个适配器,把 JSON 字段映射一下就完事了,结果接入第一个真实数据源就碰到了问题:同样是“会议”,Google Calendar 返回的是带conferenceData的对象,Microsoft Graph API 返回的是带onlineMeeting的对象,而飞书文档里的日程又是另一套结构。字段名不一样还好说,关键是语义不一样,有的表示“会议地点”,有的是“会议链接”,有的是“物理会议室 ID”。

所以在设计存储层的时候,我做了两个关键决定。

3.1 保留原始数据,只在上层做归一化

我在每个 TimeBlock 里保留了raw字段,直接从数据源返回的 JSON 原封不动存进去。这样做的原因是我深知一个道理:归一化过程是有损的,你现在觉得用不到的字段,未来某个聚合规则可能就要用。比如我第一次只做了时间、标题、来源的映射,后来想加一个“会议是否有视频链接”的维度,如果没有raw,我就要回溯数据源重新拿全量数据,非常被动。

存储上我用了 SQLite,表结构简单粗暴:time_blocks、sources、rules、raw_payloads。raw_payloads和time_blocks是一对一关系,但单独成表,这样查询时间轴时不用带着大 JSON 跑,性能好很多。如果你是想做一个轻量化的本地工具,这个设计值得参考,不要一上来就上 PostgreSQL 加迁移工具,很多时候 SQLite 完全够用,而且备份就是拷一个文件,极省心。

3.2 时间存储统一用 UTC,展示时再转本地

这个是我踩坑踩出来的经验。最开始我用本地时间直接存,结果在夏令时切换的那一周,所有跨午夜的事件都偏了一个小时,排查了半天发现自己根本没考虑时区问题。后来统一改成所有时间戳存 UTC,展示层通过浏览器的Intl.DateTimeFormat转成本地时区。还有一个细节是历史事件的固定时间和周期性事件的下一发生时间要分开处理,周期性事件存的是 RRULE 规则字符串,而不是展开后的一堆实例,这样数据的体积和可维护性都好了很多。

举个例子,一个“每周一 10:00 站会”的事件,我存的是:

{ "source": "company_calendar", "title": "站会", "rrule": "FREQ=WEEKLY;BYDAY=MO;BYHOUR=10;BYMINUTE=0", "duration": 30, "weight": 0.4 }

查询某一天的时间轴时,实时计算这个 RRULE 在该天的发生时间,而不是提前生成一年的实例再存储。节省的空间是一方面,更重要的是修改规则只需改一条记录,改一年实例那种操作想想都头大。

3.3 附带的启发:字段命名要有“业务气味”

还有个小点,是我在重构字段名时悟出来的:命名不要一味图短,要带着业务语义。我的 TimeBlock 表里有一个字段叫attention_span_minutes,其实就是“预计要花多少分钟”,但我不想叫duration_minutes。因为 duration 暗示这个时间段是固定的,而 attention span 表达的是一种预估的、可调整的认知投入量。这个区别在做冲突检测的时候是有意义的:固定时长(会议)不可压缩,预估时长(写代码)可以延后或缩短。字段命名带着业务气味,后续写规则引擎的时候,读代码的人会少踩很多坑。

4. 核心引擎实现:规则计算、冲突检测与建议窗口生成

数据模型设计好之后,核心就是把拼接规则变成可执行的引擎。OpenMontage 的引擎不算复杂,但逻辑链比较长,我拆成四个模块来讲。

4.1 归一化适配器:每个数据源一个管道

我给每个数据源写一个适配器(Adapter),统一输出 TimeBlock 结构。适配器的接口很简单:

def fetch(source_config) -> list[RawItem] def normalize(raw_item: RawItem) -> TimeBlock def enrich(time_block: TimeBlock) -> TimeBlock

fetch负责从数据源拉数据,normalize负责字段映射,enrich负责补充上下文信息,比如给来自邮件的事项打上“客户相关”的标签。这个接口之所以能稳定,是因为我坚持了“原始数据与归一化数据隔离”的原则,适配器只做转换,不做规则判断。优先级计算、冲突检测、窗口推荐都是后续引擎层的事,这样可以保证新增数据源的成本被压到最低。

4.2 优先队列合并:让时间轴有序

当所有适配器跑完,我会得到几百上千个 TimeBlock,它们的来源、时间、权重各不相同。聚合第一步是生成一条有序的时间轴,这里我用了优先队列(Priority Queue)的思路,把每个 TimeBlock 按start_at排序,同时维护一个基于priority_score的辅助队列,用于重叠时快速取最高优先级块。

priority_score的计算公式在下发规则里配置,默认是:

priority_score = source_base_priority * 0.6 + weight * 100 * 0.4

你可以通过规则覆写,比如“所有与某个标签相关的事件默认加 10 分”。这样设计的好处是规则引擎不用写死判断逻辑,用户可以自定义表达式,引擎只负责执行。

重叠块的最终展示逻辑是:主块完整显示,次块显示为一条窄色条,点击后展开详情。这其实借鉴了地图应用“主路与辅路”的分级显示思路,核心信息不被淹没,辅助信息又不丢失。

4.3 窗口推荐器:反向匹配空闲区间

窗口推荐逻辑我花了不少心思。需求是这样的:你有一个不固定时间但需要 3 小时专注的任务,系统要帮你找到本周比较合适的空闲时段,然后建议你安排进去,而不是让你自己瞪着日历找。

实现思路分四步:

  1. 获取七天时间窗口,切成 30 分钟的槽位。
  2. 标记已被固定事件占用的槽位,以及被阻断规则禁用的槽位。
  3. 对剩余空闲槽位跑一次“连续可用”检测,找出所有长度不少于任务预估时间的连续空闲区间。
  4. 对每个候选区间打分,评分参考三个维度:是否靠近截止时间(越近分越高,因为要避免最后时刻突击)、是否是个人高效时段(可以自定义 9:00-11:00 加分)、与既有事件的间隔(前后都有 1 小时缓冲加分)。

最后把得分最高的区间作为建议窗口推荐给用户。这个模块写完之后,我用自己三个月的历史数据做了一次回测——把所有已完成的固定任务抹掉,只留下截止时间和预估时长,看系统推荐的窗口和当时实际安排的重合度有多高。结果大概在 65% 左右,虽然不算特别高,但考虑到当时很多安排是基于临时社交压力的,这个准确率我已经比较满意了。

另外这里有个细节:反向匹配这个概念是我在实现过程中从算法名中学到的,本质上就是根据任务量和截止时间向前推导,而不是从头往后硬排。这种设计更符合人在真实世界里的行为,人都是先看到“某个日子要交东西”,然后心里默默往前推“那我提前两天得开始做”。

4.4 冲突检测:只报“真冲突”,不管“假重叠”

冲突检测一开始我是想做成“发现所有重叠就报警”,但很快就发现误报率太高。一个 10:00-10:30 的会议和一个 10:15-10:45 的写代码任务,它们重叠了 15 分钟,但你如果报“冲突”,我大概率会忽略这个提示,因为人本来就会在会议前后零碎时间做些不需要完全专注的小事。

真正的冲突定义应该是“两个块都需要高专注度且无法并行”。所以我引入了attention_type这个字段,表示这个块是“浅专注”(回消息、归档文件、刷资讯)还是“深专注”(写代码、写方案、审稿)。只有当两个高专注块的重叠时间超过 15 分钟,系统才标记为冲突。浅专注与深专注重叠、或两个浅专注重叠,都只是记入“拥挤度”指标,不弹冲突。

这个区分非常关键,它决定了你的工具是一个让人焦虑的闹钟,还是一个有分寸感的助理。

4.5 规则引擎的落点:结构化配置,而不是硬编码

最后说下规则引擎的实现。我没有引入 drools 之类的大型规则引擎,因为对于个人工具来说太重了。我把规则定义成一个可序列化的 JSON 结构,存放在rules表里,引擎启动时加载解析。比如一条规则可以是:

{ "id": "rule_deep_work_block", "type": "block", "conditions": { "time": "9:00-11:00", "days": ["Tue", "Thu"] }, "action": "forbid", "scope": "all_unless_priority_gt_85" }

引擎执行时就是遍历规则,对每个 TimeBlock 做条件求值,然后生成结果标记。这样的好处是你可以直接编辑 JSON 调规则,不用改一行代码,对非程序员用户也友好。

5. 视图层的实战细节:当拼接遇到交互,问题就来了

聚合引擎跑得再好,最后用户面对的还是一个页面。视图层看起来简单,实则有一堆隐藏的复杂度,尤其是“时间块密集”时的交互处理。我挑三个开发中最头疼的点来说。

5.1 密集时间轴的“卡片坍缩”策略

时间轴上如果同时有二十个块,全都展开显示是不可能的。我的方案是把时间轴分成三种密度模式:舒展模式(单块宽度超过 28px 时全部平铺)、紧凑模式(块宽度在 14px 到 28px 之间时,低优先级块显示为色条)、熔岩模式(块宽度小于 14px 时,只显示一个由高优先级块组成的分区条带,低优先级块全部折叠到底部的“溢出区”)。

密度模式不是手动切的,是根据当前缩放比例和设备宽度自动计算的。这个“坍缩”策略的好处是当你需要浏览全局时不会被细节打扰,当你放大到某一天时该有的信息又全部回来了。

5.2 拖动改期:要考虑的不只是新时间

给 TimeBlock 提供拖动改期的能力,听起来很简单,做起来远不止“更新 start_at”这么简单。拖过去之后至少要回答三个问题:

  1. 新时间段是否与固定事件冲突?如果冲突,需要选择覆盖、顺延还是拒绝操作。
  2. 这个块如果带有一串依赖任务(比如“准备方案”依赖“收集素材”),移动它的同时要不要联动后面依赖项?
  3. 如果这个块来自单向同步的数据源(比如公司日历),本地改了之后要不要反向推送?推送失败怎么办?

我的做法是:拖动只改变建议时间,不直接改数据源的原始数据。OpenMontage 会标记这个块处于“本地调整”状态,并把修改建议写进一个overrides表里,数据源同步时可以决定是否采纳。这种“建议优先”的思路减少了误操作对远端数据的影响。

5.3 周视图的“拥挤度”热力底色

我给周视图加了一层热力底色:每个小时格子根据被占用的百分比显示不同深浅。这个在技术上很简单,就是遍历格子的时候统计一遍sum(overlap_minutes) / 60,然后映射到颜色值。但它带来的产品价值很大——你一眼就能看出周三下午已经红得发黑了,就不会再把新会议往那个时段塞。

甚至我还加了“反向建议”:如果某个格子的拥挤度超过 80%,系统会在旁边提示“本周三建议只安排关键会议,其余可延至周五”。这种建议虽然只是简单的统计触发,但给人的感觉是工具真的在思考,而不是机械地罗列日程。

6. 性能优化:当数据量上来以后,聚合会变成瓶颈

一开始我天真地以为 SQLite 加上千把条 TimeBlock 根本不是问题,但实际做到周视图渲染时还是卡了。瓶颈不在数据库查询,而在聚合计算和前端渲染的联动。

6.1 查询剪枝:按“可视窗口”加载,不一次性全查

我的第一个优化是查询剪枝。用户打开周视图时,只需要当前窗口的数据,但我之前的 API 会把整个月的 TimeBlock 全部返回。后来改成根据视图时间范围动态生成 SQL WHERE 条件,让数据库只返回窗口内的数据。配合created_at与updated_at索引,查询从几百毫秒降到了几十毫秒。

这一点对任何数据密集型工具都有普适性:宁可多查几次,也别一次拉全量,尤其在移动端网络环境里,全量数据会造成明显卡顿。

6.2 增量聚合缓存:规则没变就复用结果

聚合计算本身并不重,但如果每次打开页面都重新把所有规则跑一遍,体验还是打折扣。我加了一层简单的缓存策略:以source_last_sync_at + rules_version + view_range作为缓存 key,命中缓存就直接返回之前算好的聚合结果,同时用 Web Worker 在后台做增量重算,完成后主动推送更新。

我用了浏览器端的 IndexedDB 做这个缓存,说实话这个方案不是最优的,因为多端同步比较麻烦,需要手动租约失效。但如果你的场景是单机个人工具,这是成本和收益最平衡的做法。

6.3 渲染层:虚拟滚动是续命神器

当时间轴上很多块同时存在时,直接渲染 DOM 节点一定会卡。我给单轴时间线和周视图都上了虚拟滚动(virtual scrolling),只渲染可视区域内和上下预加载范围内的卡片,其余位置用空白占位符填充。这个技术本身不新,但如果你要做同类聚合视图,它基本是必选项。

还有一个小细节:TimeBlock 的颜色不要用太多种类,最多 8 到 10 种,否则视觉识别负担会很大。我是按来源生成色相,同一来源的不同块只在明度上有细微差异。用户看久了以后,哪怕不看文字,光靠颜色也能快速定位“哪些是工作日程、哪些是个人安排”。

7. 可扩展性:让“开放”名副其实,而不是一句口号

OpenMontage 之所以叫 Open,就是因为它不应该是一个封闭的、只能接固定几个服务的工具。扩展性我在设计时主要考虑了三层:数据源接入层、规则编写层、展示主题层。

7.1 数据源接入层:适配器模式加简单元数据

任何第三方开发者都可以写一个适配器,只需实现fetch、normalize、enrich三个方法。为了让适配器健壮,我定义了标准的适配器元数据结构,包含名称、图标、默认优先级、同步周期、是否支持反向推送等字段。适配器写成 Python 类后放到adapters/目录,工程启动时自动发现并注册。这也是我见过的最稳妥的插件机制——不需要复杂的组件扫描框架,一个约定目录加一个注册函数就够了。

7.2 规则编写层:表达式字符串,别锁死用户

规则定义里不少地方需要用户写条件。我没有让用户写 JSON(对非程序员不友好),而是提供了一套简单的表达式语法,比如:

priority("company_calendar") > 80 && has_tag("urgent") => boost(15)

这套语法是语法分析器解析的,支持&& || !操作符和几个内置函数(priority、has_tag、is_between、has_duration)。用户可以在界面里通过配置器生成表达式,不用手写,但我保留了直接编辑高级模式的入口。这样既能照顾新手,又能满足老手。

7.3 展示主题层:视图是可替换的渲染器

最后是展示层。我认为聚合工具的核心价值在数据层和规则层,所以 OpenMontage 的视图是可替换的。默认提供时间轴和热力周视图,但开发者可以通过渲染器接口实现自己的视图,比如“以项目为轨道的时间线”或者“以标签为聚合维度的矩阵图”。视图渲染器接收的数据是聚合后的 TimeBlock 数组,不关心底层存储,这使得做一个新视图的成本很低。

我甚至用这个机制实现了一个“主题式聚合视图”:把所有块按标签分组,每组生成一个摘要卡片,周报就是把这些卡片翻译成自然语言。这个功能虽然不算成熟,但让我意识到——好的聚合工具最终应该具备“生成叙事”的能力,而不止是展示时间轴。

8. 开发中的几个典型坑和我的处理方式

这一节专门记录我在开发 OpenMontage 过程中踩过的一些比较有代表性的坑,很多都是文档里查不到的。

8.1 问题一:夏令时把跨午夜事件搞乱了

前面提到过,最初我用本地时间直接存储,结果夏令时切换那一周所有跨午夜事件偏移一小时。具体表现是:一个原本 23:00 开始、次日 01:00 结束的事件,在夏令时开始后显示成了 22:00 到 00:00。修复方案是所有时间戳统一转 UTC 存储,展示层再做时区转换。这个坑本质上是“存储层语义不纯净”导致的,时间数据如果不统一到一个基准,任何聚合比较都会埋下隐患。

8.2 问题二:RRULE 展开时卡死

我最初处理周期性事件时,是用一个 Python 库直接展开一年的所有实例存进数据库。结果某个每 15 分钟重复一次的自定义规则,展开后直接生成了 35040 条记录,数据库瞬间膨胀,查询也变慢了。后来改成存 RRULE 字符串,查询时实时用dateutil.rrule计算窗口内的实例,记录数降了一个量级。这里的关键认知是:周期性规则是“二维”的(规则 + 时间窗口),展开成“一维”的实例列表是反模式,应该只在查询时按需展开。

8.3 问题三:规则引擎优先级表达式太复杂,调试困难

一开始我把规则表达式做得过于灵活,支持任意嵌套函数调用,结果是用户写了一条规则后,很难判断它到底为什么生效或者没生效。后来我加了一个调试模式:系统会计算每个 TimeBlock 的规则命中记录,显示命中规则列表和对应的得分贡献。这样用户可以逐步理解自己写的规则在时间轴上的影响。调试工具对规则引擎来说不是可选的,是必需品。

8.4 问题四:反向推送的循环同步

当我给数据源加了“本地可改期”的能力后,出现了循环同步路径:用户在 OpenMontage 改了时间,同步到公司日历,公司日历又通过适配器同步回来,系统再次误判为“用户改回去了”,于是又推回去,形成循环。解决方案是给每次同步生成一个sync_origin_id,适配器收到包含该标记的数据时,只更新本地显示而不反向推送,从源头打断循环。

9. 一个非程序员的视角总结:这套思路还能用在哪

说实话,OpenMontage 的技术栈并不复杂:Python 加 SQLite 加一个前端框架,任何有两年后端经验的人都能写出来。但我觉得这个项目最有价值的不是代码本身,而是它背后那套“把碎片信息拼接成有序整体”的思维方式。这套思路的应用面远不止日程管理。

如果你做项目管理,你可以把各条流水线的产出、风险、依赖全部作为 TimeBlock 拉到一个时间轴上,观察它们的重叠和冲突;如果你做内容运营,你可以把选题、生产、排期、发布的关键时间点拼接成一条内容时间线,让节奏一目了然;哪怕你只是管理自己的生活,把健身、阅读、社交、家务都抽象成时间块,你也能一眼看出自己是不是一直在“假装忙碌”。

我在做这个项目的过程中,最深的体会是:工具的意义不是帮你记住更多事情,而是帮你看见那些被切碎的时间块之间的关系。拼接本身不是目的,生成一个你能真正理解的叙事线才是。OpenMontage 现在还在迭代,但哪怕它永远只服务我自己一个人,我也觉得这套把信息“蒙太奇化”的思路值回票价了。

如果你也想动手做类似的聚合工具,我的建议是:先别急着优化技术,先花时间想清楚你要拼接的对象和规则,然后把“原始数据永不丢失”和“规则可调试”这两条原则焊死在设计里。做到这两点,这个工具的底座就有了。