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: FREQWEEKLY;BYDAYMO;BYHOUR10;BYMINUTE0, 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) - TimeBlockfetch负责从数据源拉数据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 小时专注的任务系统要帮你找到本周比较合适的空闲时段然后建议你安排进去而不是让你自己瞪着日历找。实现思路分四步获取七天时间窗口切成 30 分钟的槽位。标记已被固定事件占用的槽位以及被阻断规则禁用的槽位。对剩余空闲槽位跑一次“连续可用”检测找出所有长度不少于任务预估时间的连续空闲区间。对每个候选区间打分评分参考三个维度是否靠近截止时间越近分越高因为要避免最后时刻突击、是否是个人高效时段可以自定义 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”这么简单。拖过去之后至少要回答三个问题新时间段是否与固定事件冲突如果冲突需要选择覆盖、顺延还是拒绝操作。这个块如果带有一串依赖任务比如“准备方案”依赖“收集素材”移动它的同时要不要联动后面依赖项如果这个块来自单向同步的数据源比如公司日历本地改了之后要不要反向推送推送失败怎么办我的做法是拖动只改变建议时间不直接改数据源的原始数据。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 现在还在迭代但哪怕它永远只服务我自己一个人我也觉得这套把信息“蒙太奇化”的思路值回票价了。如果你也想动手做类似的聚合工具我的建议是先别急着优化技术先花时间想清楚你要拼接的对象和规则然后把“原始数据永不丢失”和“规则可调试”这两条原则焊死在设计里。做到这两点这个工具的底座就有了。