main
45
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
35632a3547 |
Give the site a favicon
The tab has said "Runway" next to a blank page-shaped placeholder since M1. Two files, both generated from the 1254px source: a multi-resolution `.ico` at 48, 32 and 16, and a 180px apple-touch-icon. The source has about 114px of transparent margin on each side, which at 16px is a pixel and a half of nothing on every edge -- so it is trimmed before being resized, and re-centred on a square canvas first, because the trimmed artwork is 1026x1036 and every downsample from it otherwise comes out a pixel narrower than it is tall. At 32px the plane, the swoosh and the calendar grid all still read. At 16px the grid flattens to a pale block; that is the artwork's level of detail rather than the resampling, and 32 is the one most displays ask for. They are copied rather than hashed. Trunk renames an icon asset the way it renames the stylesheet, and these two are the only files in the bundle whose URL is not ours to choose: Caddy serves the frontend with an `index.html` fallback, so anything that guesses at `/favicon.ico` rather than reading the tag -- a bookmark, a feed reader, a browser with no page loaded -- would get the page's HTML with a 200. That renders nothing while looking like it worked, which is the failure mode this repository keeps finding in other places and is no more welcome here. The apple-touch-icon is the one that is flattened onto the icon's own blue. iOS composites a home-screen icon onto black and then applies its own corner mask, so a rounded square that still has an alpha channel arrives with black sitting in the corners it had already rounded off. `assets` joins the `[watch]` list for the reason written above `[watch]` already: Trunk rebuilds on what it is told to watch, and without it changing an icon in development changes nothing and the browser keeps serving the previous one. |
||
|
|
50cb77556d |
Deploy the backend, not just the frontend
`compose up -d` decides whether to replace a container by comparing its configuration, and the configuration is the string `runway:latest` whether or not the image behind that tag has moved. It never moves. So every deploy since this script was written swapped the frontend and left the backend exactly where it was, and the first one to matter was the one just now: the sidebar's new editor asks `/api/calendars` for `own_color`, and the backend answering it was built three days before that field existed. This is the third variation on one theme and the worst of them. The first failed loudly, the second failed quietly and exited 1; this one reports success, and what it leaves behind is precisely the state the single-image design exists to prevent -- a frontend calling an API the running backend does not have. `--force-recreate` is the fix. The check after it is the part worth keeping. Nothing above it could have caught this, because nothing failed: podman was asked to bring a service up, it was already up, and it said so. So the last thing the script does before recording a deploy is compare what is actually running against what it just pulled, and refuse to write the stamp if they disagree. A run that half-happens now leaves no record, which is what makes the next tick try again. |
||
|
|
227e21bc04 |
Run rustfmt over the new module
Three assertions past the width. Caught by CI rather than here, because I ran the tests, clippy and the wasm target and not `cargo fmt --all --check`. |
||
|
|
fd298f4977 |
Add and change what the sidebar lists
Every one of these calls has existed in the typed client since M9 and M10 and had nothing wired to it: create, delete and update for calendars, subscribe, update and unsubscribe for feeds. The only field the interface ever sent was `visible`, from the checkbox. So a person could see the calendars on their server and hide them, and could not make one, rename one, recolour one, reorder them, or subscribe to anything at all -- the "Subscribed" section did not even draw unless a feed was already in the database, which no interface could put there. M14 was scoped as "calendar list, visibility toggles" and nothing after it picked up the rest. It goes next to the list rather than into Settings. The sidebar already is the list of sources; Settings is about the person reading them. A calendar's name and colour are neither -- they belong to the calendar, they are written to the CalDAV server, and every other client sees them. That distinction needed one thing from the API. `color_source` was already reported so the interface could offer "use the calendar's own colour", but the colour itself was only ever sent after the override was applied, so an editor had no way to know what the calendar's own colour was. One control for both would have written somebody's private override onto the shared calendar the first time they saved anything. `CalendarView` now carries `own_color` beside `color`, and the editor shows them as the two different things they are. Reordering sends only the rows that actually move. Each one is a PATCH and a round trip to the CalDAV server, and "no position at all" is a different state from "position 0" -- so the first move on a fresh account writes every row and every move after it writes two. The arithmetic for that is pure and tested, as is turning a CalDAV colour into something a colour input will accept: Apple's `calendar-color` is `#RRGGBB` or `#RRGGBBAA`, and a control with no notion of alpha reads the eight-digit form as garbage and silently shows black. Deleting a calendar destroys everything in it and there is no undo underneath, so the menu item leads into the editor with its confirmation already showing rather than doing it from the menu. The menu offers what exists: a calendar at the top of the list is not offered "Move up". The browser checks in `shoot.mjs` cover the whole round trip against a real server -- made, coloured, renamed, deleted -- and that a link which cannot work is refused before it costs a request. They cannot run yet: the file stops at its all-day overflow assertion, because `seed.py` hard-codes the week of 2026-08-24 and that week has now passed. That is a separate problem and older than this change. |
||
|
|
e1e253319e |
Stop the deploy succeeding without deploying
The chown from the last commit unblocked the updater, and the updater then went on not deploying -- quietly, and now with exit 0. Three separate reasons, none of which the script could see from where it stood. It compared the image it had just pulled against the image it had before, which answers "is there anything new?" rather than "is what is running current". The failed run had already pulled, so the next run saw nothing new and stopped. It now records the image in `dist.deployed` at the end of a run that finished, and compares against that; a run that dies half way leaves no record and the next one does the whole thing again. It deployed into the wrong podman. The stack on this machine -- Caddy, Authelia and forty others -- is root podman, and the timer was a user unit, so `podman compose up` built a perfectly healthy backend in a rootless namespace that Caddy has no route to, and said so approvingly. The unit is now a system unit. That failure is worth naming because nothing local to the script can detect it: the container starts, the command succeeds, and the only symptom is somewhere else entirely. And Caddy could not see the frontend anyway. A bind mount holds an inode, so renaming `dist` out from under it leaves Caddy serving the directory that was there when it started -- which is also why rolling back by `mv`, as the README promised, would have done nothing at all. Caddy now mounts `web/`, the holder, so the rename happens inside what it can see. The database stays outside that directory, which is the reason for the extra level rather than mounting `data/runway` itself. One more, found by fixing the second: a container started from a `oneshot` unit inherits that unit's cgroup, so systemd tore down the backend the moment ExecStart returned. `restart: unless-stopped` put it back, which is precisely what made it hard to see -- the site was fine and the unit was red. `KillMode=process` leaves the container where podman put it. |
||
|
|
ff52fa1a1c |
Say why a deploy could not happen
The updater pulled the image, failed, and exited 1 with nothing in the journal explaining itself. The cause was ordinary -- `~/data/runway` is root-owned, left that way by v1, which deployed by `sudo rsync` over SSH and so never needed a user to write there. Replacing the frontend swaps one directory for another, which is a write to the directory holding them. The permission is the smaller half of this. A deploy that fails without saying why is worse than one that fails loudly for a stupid reason: the timer runs every ten minutes, so it would have gone on not deploying, quietly, at a distance from anyone reading the log. The check now runs before the pull and prints the exact `chown` to fix it, and the requirement is written down in the setup steps where it should have been in the first place. |
||
|
|
8df5860c75 |
Build the image on the branch that exists
The release workflow watched `master`. The repository's branch is `main`, so the job that builds and pushes the image would have sat there never firing, and the first sign of it would have been a deploy that never arrived. The check workflow runs on every push and so was never affected, which is exactly why this needed finding on purpose rather than by noticing. |
||
|
|
d156b4eb15 |
Drop two dependencies nothing was using
The first thing CI caught, on its first run, in the commit that added it. `runway-caldav` depended on serde and never mentioned it. `runway-cli` depended on tracing while using tracing-subscriber, which is a different crate; the subscriber stays. I had run `cargo machete` before committing the workflow and reported it clean. It was not: the tool prints a paragraph of advice about ignoring false positives *when it finds something*, and I had tailed the output, seen that paragraph and "Done!", and cut the two findings off the top. So the check I claimed to have made was the one thing I had not made. Which is a decent argument for the guardrail existing. v1 shipped reqwest, ical, regex and indexed_db_futures into a WASM bundle that used none of them, and it shipped them because nobody was looking -- not because looking was hard. |
||
|
|
b0da3afafc |
Build and ship both halves from one commit
The audit's other "do it differently" (F76). v1's backend was an image built by CI; its frontend was `rsync -azX --delete` from a laptop, run by hand, with the API's absolute URL compiled into the WASM by an environment variable set in the same script. Two artefacts, one pipeline, and nothing keeping them in step. Here there is one image with the server binary at `/usr/local/bin` and the built frontend at `/srv/dist`, and the frontend asks for `/api/...` relative to wherever it is served -- so the artefact is the same in every environment and the two halves cannot be deployed apart. Caddy still serves the static files from a directory, because that is what the reverse proxy in front of everything already does; the updater lifts them out of the image rather than out of a build on somebody's machine. It stages them and moves the directory into place, since `index.html` names hashed files and a browser that fetches new HTML with old JavaScript gets a blank page. The image is 97 MB and holds no build tooling. Migrations are compiled into the binary and run at startup, and the database is created if it is missing, so there is no `sqlx-cli`, no entrypoint script, and nothing that can decide to carry on after a failed migration -- v1's start.sh ran migrations with `|| echo "Migration failed but continuing..."`. TLS is rustls with its roots compiled in, so there is no OpenSSL to keep patched. There is no dummy-source dance for dependency caching either; a BuildKit cache mount does what that trick was inventing, and the binary is copied out of the mount because a cache mount is not part of the layer. Deployment is a timer on the server rather than CI reaching into it. Nothing in the workflow holds a credential for the machine it deploys to, and a bad build cannot take the site down on its own; the cost is a few minutes between push and deploy, and `deploy/runway-update` for when that is too long. The rest of this is the CI that was promised at M1 and never written. "Rules that only live in a doc get forgotten, so these are lints and CI checks." The lints landed. The CI half did not exist, which meant for twenty-seven milestones the forgetting was still perfectly possible -- it was just mine rather than the repository's. `cargo fmt`, `clippy -D warnings` and the whole test suite now run on every push, alongside the three guardrails that were only ever configuration: `cargo machete` passes. `deny.toml`, written at M1, did not: three permissive licences its allow-list had not anticipated, and two `unmaintained` advisories arriving through Leptos's macros. Both are now allowed by name with a date and a reason rather than by widening a category. MPL-2.0 came out of the list, because nothing uses it and an allow-list should say what is actually there. That file had been quietly wrong since the day it was written, which is the argument for CI in one line. The bundle budget is 1.8 MB against today's 1.31 MB and v1's 2.5 MB, printed on every run. A bundle grows one convenient dependency at a time. The print rule is deliberately not in CI: it already exists as a test, it covers the CalDAV client too, and a shell grep cannot tell a call from a comment about a call -- the first draft of that step failed on the paragraph in `observability.rs` that explains the rule. Two things the image found that reading could not. `/db` is created in the image now. SQLite creates the database file if it is missing but not the directory holding it, so the container started only when something happened to be mounted there and otherwise died with "unable to open database file", which says nothing about what is wrong. And the graceful shutdown only listened for Ctrl-C. A container runtime stops a service with SIGTERM, waits ten seconds, and sends SIGKILL -- so `podman stop` took ten seconds and killed the process outright, and the handler written to stop a restart dropping a CalDAV write half-way through worked everywhere except deployment, which is the one place restarts happen. It listens for both now, and the container stops in two. |
||
|
|
907a0f2b01 |
Let a shape change more than the corners
The shape axis shipped as four radii, a border width and three shadows, and turning the dial barely moved the app. Two reasons, and the second is the embarrassing one. The first is that I had drawn the boundary in the wrong place. Keeping shape out of density's business is right -- it is what makes three of each six blocks rather than nine -- but "not a size" is a much weaker constraint than the one I had actually been enforcing, which was closer to "not anything". Density owns sizes and distances. The palette owns colours. Everything else about how this looks was sitting unclaimed. So a shape now owns the typeface and the weights, whether small labels are set in capitals and how far they are tracked, whether a panel has an edge or floats on a shadow and which surface it sits on, and -- the one that matters -- how an event is drawn. `--event-wash` is how much of a calendar's colour fills the box and `--event-rule` is how much of it is kept as a rule down the leading edge, so a day can read as a list of things with a colour attached rather than as a stack of coloured bars. That is the character of the whole week, and it was not previously anybody's to change. The second reason is that `--border-width` was a token almost nothing read. Seventy-nine elements take their edge from Tailwind's `border` utility, which sets a flat 1px, so the axis that is supposed to own edges was being overruled by a utility class on nearly every element that has one. Tailwind's utilities live in `@layer utilities` and unlayered declarations beat layered ones whatever the order, so five rules at the top of the stylesheet reconcile the two. Every border in this app is one of ours; there is no third-party markup here to surprise. Four shapes, then, and they are meant to be told apart across a room. Flat is square -- including the things that are usually round, because squaring the boxes and leaving today's date in a circle is not committing to anything -- with heavier weights, wider tracking and nothing raised. Soft is rounded, set in a rounded face, and its panels give up their edge entirely to be held off the page by shadow alone. Quiet washes the events and keeps the colour as a rule. `--font-ui` moved from density to shape, where it always belonged: a typeface is not a size. It had been sitting in the density blocks since M12 because it feels like it goes with the type sizes, which is exactly the kind of drift that ends in two axes moving the same thing. Three tests hold the line. One rejects any name declared by both a shape and a density -- a token owned by two axes has a value that depends on which block the browser read last. One rejects a shape that differs from the default only in radii, because that is the state this commit is fixing and nothing else would have caught it. The browser check reads the four shapes off real elements rather than off the tokens, which is the only way to notice a token that nothing is reading: it reports what actually moved, and it is the check that would have said "border width: 1px, 1px, 1px, 1px" months ago if it had existed. The weekday abbreviation got a testid, because the first version of that check probed the hour marks -- which are numerals, have no case to change, and reported "none" in every shape while proving nothing. Two things a shape still may not touch. Colours are the palette's: a shape picks which role is used, never a value. And the ring that separates two touching events of the same calendar is not a matter of taste, so every shape keeps it -- `--event-lift` is a shape's to give, the seam underneath it is not. |
||
|
|
e98d79b67a |
Make a palette seven numbers
v1 shipped twelve themes and three layout styles as thirty-six hand-maintained stylesheets: 8,300 lines of CSS, 553 custom properties, 116 `!important` declarations. Reading them now, nine of the twelve themes set nothing but an accent colour and a gradient, and four of those -- nord, dracula, midnight, charcoal -- put a dark accent on light surfaces, because no combination of the thirty-six was ever looked at. The last fifteen commits in that repository were tweaks to this. So a palette here declares seven numbers and no colours. Two hues (schemes that are a different colour by night than by day are a real thing: Solarized is cream in the light and deep teal in the dark), two tints, an accent hue and chroma, and how far from white or black its paper sits. One derivation turns those into all forty-odd tokens, down a ladder every palette shares. That last part is the point rather than a side effect. Hue and chroma barely move contrast; lightness is nearly all of it, and no palette gets to choose its lightness. Legibility stops being something each palette gets right or wrong and becomes a property of the system -- which is what makes eight of them cost less than v1's twelve, and what makes a ninth safe to add. Both polarities live in one declaration each, via `light-dark()`. "Follow the operating system" is then not a theme at all: it is the pair left unresolved and `color-scheme: light dark` handing the question to the browser. No media query, no second copy of forty names. `color-scheme` inherits and the derivation is selected on the attribute, so a *part* of the page can be in a different palette and polarity from the page around it -- which is how the print sheet already worked and now costs nothing. Four axes where there were two, because each of v1's was two questions wearing one name. Its theme list had "Nord" next to "follow the operating system", which are not the same kind of thing; its style list moved corner radii and border widths together with spacing and type sizes, so wanting a tighter calendar also meant squarer corners. Now: palette x mode, shape x density. Shape owns corners, edges and depth; density owns spacing, type and the measurements a calendar is built from. They share no tokens, so three of each is six blocks to maintain rather than nine. Four axes is only fewer things to maintain than two while they stay independent, and that is exactly the property that rots quietly. A test rejects any rule in the stylesheet selected on two of them at once, at any nesting depth, and another rejects a palette that declares anything but its seven seeds -- a palette writing a colour of its own is one opting out of the ladder. A third checks the names in the pickers against the blocks in the file, both directions, so a palette that does nothing and a block nobody can reach are both build failures. A palette of your own is four of those seven numbers, written onto `<html>` as an inline style. No new CSS: it is the same derivation reading numbers that arrived from a preference instead of from a rule. Lightness is deliberately not among them, so there is nothing to drag it to that cannot be read. The bounds on those four are not decoration. The browser check sweeps all sixteen palette-and-polarity pairs and all four corners of the custom sliders, and it caught the first accent ceiling at 4.25:1 -- a very saturated hue gains luminance at a fixed lightness without gaining any of the contrast that buys. The ceiling is now exactly the loudest shipped palette. It also caught two rungs of the ladder that Nord's lifted paper had pushed under the floor, which is what `--lift` costs and why the faint end sits lower than it would need to on paper-white. `--danger` and its friends are the one part a palette has no say in: a red that means "this deletes something" stops meaning it the moment it is allowed to be the palette's idea of red. The sheet takes a palette and a polarity separately now, which makes the common thing sayable. "Print this lighter" is a mode; before, it could only be said by printing in somebody else's colours. `theme` and `style` became `palette`, `mode`, `shape` and `density` in the preferences, with the custom palette's four numbers as four typed columns rather than a JSON document -- v1 kept colours, visibility and a theme name in one blob and could not constrain any of them. The polarity is checked by the schema and the palette is not, on purpose: there will never be a third polarity, so a third value means the data is wrong, while palettes are a list that is expected to change and somebody whose favourite was retired should get a readable calendar rather than a row the database refuses to hand back. |
||
|
|
19dbb60ad1 |
Print in the theme the calendar is read in
Paper was white in every theme, on the reasoning that a dark scheme is a choice about a screen in a room. That is a reason to offer white, not to impose it: a week printed in a scheme you have never seen is a different document from the one you were just looking at. So the sheet carries `data-theme` itself. Every theme in the stylesheet is an attribute selector rather than something only `<html>` can wear, so this cost one attribute and no new tokens, and the sheet can be in a different theme from the app around it — which is what "print this one lighter" means. The picker sits with the paper and the orientation, starts at whichever theme is being read in, and changes the sheet alone: it is a decision about one piece of paper, not about the calendar somebody goes back to afterwards. Unset rather than copied, for the same reason the hour band is: this page can be opened cold, and the stored preferences arrive after the first paint. A sheet that took a copy of the theme when it mounted would sit in the default one for ever. The browser check now reloads the print page before looking, so that path is the one being tested. `--event-ground` is declared again on the sheet. It is `var(--surface)` written at the root, and a custom property resolves its `var()` where it is declared — so what reaches the tree is the *root's* surface, and a sheet in a different theme from the app was ringing its events in the app's colour. What was "ink: colour or saver" is now three: as on screen, on white, and ink saver. The middle one is the old behaviour — the theme's accent and the calendars' own colours, with the ground given up — and it is there for when the printer has an opinion the screen does not. Display styles will want the same treatment when there are some worth picking between. The mechanism is already here: `data-style` is an attribute selector too. |
||
|
|
410eefc5a3 |
Print a calendar the size of the paper
The audit's one "the implementation is bad and I need this anyway". v1's print preview was 617 lines of Rust and 1,320 of CSS; it cloned the DOM into a hidden node, read `client_height` back off the clone, mutated inline styles on the result, and threaded `print_mode`, `print_pixels_per_hour` and `print_start_hour` into the interactive week view so the modal could borrow it. The last fifteen commits in that repository were tweaks to it. Printing is a route here. `/print/:view/:date` draws the same components, fetched the same way, inside a box the size of the paper -- and that is the whole mechanism, because the views are grids of fractions and already fill whatever they are given. "Fills the page and nothing more" is a width and a height. Nothing measures anything. `paper.rs` is the table: what a sheet is, which way round to hold it for a view, how twelve months divide, and which hours are worth ruling. The `@page` rule the browser is handed and the box drawn on screen are formatted from the same two numbers, so the preview and the paper cannot disagree about the size. `size` is the reason any of this has to be generated rather than declared: it exists only in an `@page` rule, which cannot be selected by an attribute or read out of a custom property. Three sizes a component used to write inline became custom properties instead -- the ruler's height, the month's row template, the year's month grid. An inline declaration beats every rule in the stylesheet, and paper has to be able to disagree. The values still come from the components; only the shape of the box they go in is now negotiable. The band of hours is a window onto the same twenty-four-hour ruler rather than a second one built for print: the same grid, in a box taller than the space it is seen through, slid up by the hours being skipped. Both numbers are proportions, so the arithmetic is the browser's. Where the band starts is suggested by what is in the range -- the hours the events actually occupy, widened downwards to at least six -- and then left alone, because somebody who knows about the six o'clock start should be able to say so without it being moved back under them. The year is the view that needed the page the most, and the one whose printing was in doubt when it was built. On screen it fits as many months across as the window allows and scrolls; on paper it is four by three landscape or three by four portrait, every month filling its cell, all twelve on the sheet. Paper is white in every theme. A dark theme is a choice about a screen in a room and printing it is neither what it is for nor what a printer does well; what carries over is the accent -- today is still tinted with it -- and the calendars' own colours, with a legend across the top saying which is which. Ink saver takes the fills out and leaves a rule of colour down the edge of each event, which is enough to tell two calendars apart for almost no toner. The sheet does not answer a pointer. The views on it are the real ones and their chips still carry every handler they have, including the one that starts a drag -- which would move an event on the server while showing nothing, because the preview that follows a drag is not on that page. One declaration, rather than a print-only variant of every view. Fetching moved out of the shell into `load::in_range`, so the printed calendar and the one on screen ask the server the same question rather than two that have to be kept in step. The browser checks measure the sheet against Letter and A4 both ways round, read the page rule back, count the hour labels actually on the page for a 07:00-19:00 band, count the year at both shapes with nothing hanging off the edge, and print five PDFs with `preferCSSPageSize`: one page each at the right MediaBox, and an agenda that runs to four, because a list that does not fit is not a list with its last days cut off. One thing paper cannot promise: a month row is a fixed height, so a day holding more than the row can show is cut at the row's edge. The "+N more" count knows what the layout dropped and not what the page did. |
||
|
|
0693ce0e43 |
Say a reminder while somebody is looking
The alarms that matter were already being written to the server, which is the half that counts: a phone reads them at six in the morning with this tab long closed. Two things were wrong with the writing, and the telling was missing. `RELATED` was being dropped. The alarm row read the offset out of the trigger and wrote a new one back with `related: Start` hard-coded, so an alarm made elsewhere as "ten minutes before it ends" became ten minutes before the start the first time this app touched the event -- the exact v1 bug the model was given the parameter to prevent. It is a control now, beside the offset, and changing either keeps the other. The offsets also cover "when it starts" and "fifteen minutes after", which are things people set. `alarms::due` is the arithmetic, and it is a pure function of the events on screen and the time: only `DISPLAY` alarms, because acting on an `EMAIL` or an `AUDIO` one is not a browser tab's business; relative triggers off whichever end they name; absolute ones converted through the difference between the two spellings of the occurrence's own start, so no zone database is needed for it. A five-minute window decides what is late enough to skip -- a page opened at noon should not recite the whole morning, and one that was behind another window for four minutes should not lose what came due while it was there. The telling is one effect on the clock that was already ticking for the now-line, and one set in memory of what has been said. Either the browser says it, tagged so a second tab replaces the notice rather than stacking beside it, or the page does, in a note that can be dismissed -- never both, because the granted one arrives when the tab is behind something else, which is when a reminder is worth having. v1 spent a 2,008-line component polling every thirty seconds, a `localStorage` ledger that grew without bound, and a service worker that could not read that ledger and so did nothing but ping the tab that was already polling. Permission is asked from settings, on a click. A prompt fired at a page nobody has looked at yet is one they dismiss without reading, and a dismissed prompt is indistinguishable from here from a refusal. The panel re-reads where it stands each time it opens, since that is the browser's to change from a control this page does not own. Chips that carry any alarm say so with a small mark -- any action, not only the ones this browser would act on, because the mark means "somebody will be told" and the phone reading the same calendar is somebody. The browser checks cover both channels: a reminder due three minutes ago arriving on the page and being dismissed and not coming back on the next tick, a trigger hung off the end surviving a round trip through CalDAV, and, with the notification API standing in for the real one, the same reminder going to the browser instead and nothing being said twice. |
||
|
|
4fafed742c |
Move, resize and draw out events with the pointer
Pointer Events rather than mouse events, so one code path covers a mouse, a
trackpad, a pen and a finger, and `setPointerCapture` so a drag carried into
Wednesday keeps being delivered to the column it began in. v1 used mouse
events with `e.layer_y()` and an `e.offset_y()` fallback repeated at seven
call sites, and `pixels_per_hour = if time_increment == 15 { 120.0 } else
{ 60.0 }`.
The pointer's pixels become grid units once, against geometry read from the
page when the drag begins, and everything after that is arithmetic on times.
The same call decides what the preview shows and what is saved, so they cannot
disagree -- there is only one of them. Nothing recomputes the grid: one
absolutely positioned element is repositioned per pointer move, using the same
percentages every chip is placed with.
Dragging an occurrence of a repeating event asks which occurrences it applies
to, because it is the same edit the form makes, arriving a different way. The
sending moved to `crate::write` so both use it rather than one reaching into
the other. A new event drawn out on empty grid opens the form over that span:
it has no title yet, and an untitled event on somebody's calendar is worse
than one more step.
Three things the browser found that reading could not:
the now-line's wrapper spans its whole column, so it was swallowing every
pointer that landed on today;
`offsetY` is relative to whatever was pressed, and a press on a resize edge
lands on the edge, where every offset is nearly zero -- so every grab read
as "the top";
the chip's pointerup bubbled to the column, which ended the same drag a
second time and cleared the record that it had happened, so the click that
follows a drag opened the event that had just been moved.
The seed now empties each calendar before filling it, including the one Baikal
makes for a new account, and `dev.sh shoot` reseeds. The checks create, move
and delete real events; a run starting from whatever the last one left behind
was measuring drift as often as behaviour.
|
||
|
|
af34ca7e83 |
Ask which occurrences an edit applies to
Saving or deleting an occurrence of a repeating event now asks: this one, this and the ones after it, or all of them. v1 dispatched the same three choices on string literals -- "delete_this", "this_and_future", "all_in_series" -- in fifty-three places, where a typo was a runtime fallthrough onto the wrong branch rather than a compile error. The interesting part is that the three scopes want the event in different frames, and the form only holds one. It opens on the occurrence that was clicked -- somebody who clicked the eighth of September expects to see the eighth of September, not the Tuesday in March the series started on -- so: this one, and this and following, send the occurrence with the series' pattern stripped off it, because an override carrying an RRULE would be a second series inside one resource; all of them sends the master, with the move made to this occurrence measured and applied to where the series begins. Making the eighth of September an hour later makes every occurrence an hour later; it does not drag the series' start to September. A question with one answer is not asked. An override is already one occurrence pulled out of its series, so "this and following" and "all of them" would each be a way of throwing it away without saying so -- it saves and deletes as itself, and the form says why. Editing the whole series carries an override along rather than overwriting it, which the browser check now asserts: after changing all of them, the one that had been changed on its own is the one still saying what it said. |
||
|
|
eea7c666ce |
Add the recurrence editor
The controls emit an RRULE string. v1 sent recurrence as four separate fields -- a frequency word, a seven-long Vec<bool>, an interval and an end -- for the backend to reassemble, and the monthly options it drew a whole UI for (monthly_by_day, monthly_by_monthday, yearly_by_month) were dropped on the way to the API and never reached a calendar at all. Those are the two cases the browser check now writes to Baikal and reads back. Writing and reading the rule are one module with one test suite, because the only thing that matters is that they agree: a rule opened in the form and saved untouched has to reach the server as the same rule, and that is the assertion. What it refuses is the point. RRULE is far larger than any reasonable set of controls, so a rule carrying BYSETPOS, several BYMONTHDAYs or a BYWEEKNO does not parse into something editable -- it is shown as itself, said so, and written back unchanged. Parsing it into an approximation and saving the approximation would silently delete the part that decides when somebody's meeting happens, which is what v1 did by another route. The tab shows the rule in English underneath, rendered from the RRULE that will actually be written rather than from the controls. Anything the writer and the reader disagree about therefore shows up as raw text instead of as a confident sentence. |
||
|
|
49b685f9b1 |
Add the event form
Tabbed, and driven by the VEvent itself. v1 had EventCreationData -- a form
model carrying its own copies of EventStatus and EventClass -- converted to
the wire format through a twenty-two element tuple that serialised enums with
format!("{:?}").to_uppercase(). Fields were lost at every hop between the four
representations, which is how the monthly recurrence options came to exist in
the form and never reach the API at all. Here the signal holds a VEvent, every
control writes part of that one value, and what is sent is what was held.
Reading dates back out of it is the only part with anything to get wrong, so
that is a module of its own with tests: an all-day event's stored DTEND is the
day after it finishes, and a form showing that would ask somebody to type a
date the event does not happen on. Moving the start carries the end with it,
because typing a new day does not make a meeting longer.
A right-click on empty grid offers a new event at the hour it was aimed at,
snapped down to the chosen granularity -- being given 09:45 for a click below
the ten o'clock line reads as having missed. Editing opens from the menu or
from the detail panel, and deleting takes two presses because there is behind
it no undo.
What the form will not open on is an occurrence that has been changed away
from the rest of its series: that is addressed by RECURRENCE-ID and written
with a scope, and sending its VEvent as the series master would overwrite
every other occurrence with this one's copy. A repeating event it will open,
and says so above the fields -- saving changes all of it, and the dates shown
are the series' own rather than the occurrence that was clicked.
|
||
|
|
b28ef7013f |
Close the settings panel by clicking away from it
It was a `details` element, which only closes when its own summary is clicked -- so a panel opened by accident had to be dismissed by finding that button again. Everything else that opens over the page closes by clicking away or on Escape, and a control that behaves differently from its neighbours is one the hand has to think about. The toggle sits above the backdrop rather than under it. Under it, a click on the button would be caught by the sheet that means "not this", closing the panel, and then reopen it on the way back up. |
||
|
|
c36d52ce25 |
Add the detail panel and the context menus
Clicking an event shows everything it holds -- which is a lot more than v1 ever showed, because v1's wire format flattened attendees and categories to comma-separated strings and dropped the rest of the RFC on the way out. Here the whole VEvent arrives intact, so the panel shows all of it, and only the parts that are there: a panel of empty rows is a panel nobody reads. That meant saying RFC 5545 out loud. FREQ=MONTHLY;BYDAY=1MO and TRIGGER;RELATED=END:-PT1H are exact and unreadable, so there is a module that turns them into English -- and it says only what it can say. A rule with a part it does not understand comes back as the rule itself rather than as a confident sentence with the deciding half missing; paraphrasing BYSETPOS into silence would misdescribe when somebody's meeting actually happens, which is the same move as v1's title-matching deduplicator. Right-clicking an event or a day opens a menu, positioned by arithmetic on sizes the stylesheet guarantees rather than by rendering it and measuring -- measuring means a frame in the wrong place and imperative DOM surgery inside a declarative framework, which is how v1's print preview reached 617 lines. The tokens it reads are registered with @property so their computed values come back as lengths; an ordinary custom property reads as the string "13rem", which is not a number. The menus offer what exists: open the details, go to that day, that week, that month. Editing, duplicating and deleting arrive with the milestones that build them. An item that greys itself out is a promise the interface cannot keep, and v1 shipped a recurrence form full of fields that reached no API. What is open lives in one place, offered through context rather than passed down: the thing that opens a menu is a chip several components deep, and the ones in between have no business carrying a callback they never call. |
||
|
|
32598427cd |
Add the now-line, and make the overflow indicators go somewhere
The now-line is the last of the view features v1 had and this did not. It is drawn on today's column only, over the events rather than under them -- a line visible only when nothing is happening is a line for the hours nobody needs it -- with the clock itself in the gutter, which is the part that says what time and the part v1 never had. It moves on a thirty-second interval; v1 re-rendered its entire 1,431-line week view every five seconds to move the same line. Which meant answering what time it is, and that is not the same question as what time it is where the browser is. The zone a calendar is drawn in is a preference, so somebody reading a London calendar from Denver was being shown a line seven hours out and the wrong column highlighted as today. Both now and today are asked of Intl.DateTimeFormat in the chosen zone; the browser has a zone database and the WASM bundle does not. Today is a memo over the clock, so a tick moves the line and redraws nothing else. The all-day strip caps its lanes at four, so what did not fit now opens rather than only being counted, and the strip sits on the band the themes have been declaring for it. In the month grid the day number and the "+N more" both go to that day, as does a week-view day heading: a cell has no room for twenty events and the day view has nothing but. The browser check waits for the line to move. Comparing it against the clock proves it is in the right place, which a line frozen at page load also would be for the first minute. |
||
|
|
8137557490 |
Add the agenda and year views
Neither existed in v1, so there was nothing to be inspired by and nothing to avoid -- only the question of what each is for. An agenda is a list of what is happening, so days with nothing on them are left out, and a multi-day event is listed on every day it covers: a Wednesday in the middle of a conference is not a free Wednesday. What is not wholly inside a day says so rather than inventing times for itself -- "until 2:00", "from 22:00", "all day" for a day in the middle of something longer. An evening ending at midnight ends at 24:00, because 19:30 - 0:00 reads as running backwards. A year answers two questions -- when was it busy, and take me there. Every day is shaded by how much is on it, counted across all the days a week-long event covers rather than only the day it starts, and measured against the busiest day of that year so a quiet calendar and a full one both read. Every day and every month name is a button. The shading runs between two ends rather than up from nothing: the faint end has to be visible and the dark end has to keep the number on it readable. Every shade moves a cell towards the middle of the lightness range, which is where both a light and a dark number run out of contrast, so the ceiling was measured in a browser per theme rather than reasoned about. The check now holds every year cell to the readable floor for small text in all three. |
||
|
|
a38b8e185a |
Outline event boxes so touching ones stay distinct
The cascade draws one event partly over another on purpose, and two events from the same calendar are the same colour, so the seam between them was invisible. Two events merely adjacent in time had the same problem. Every occurrence box now carries two rims. The border is the fill mixed towards the page's text colour, so it darkens a fill in a light theme and lightens one in a dark theme without either colour being named -- it works for a calendar colour nobody has picked yet. Outside it, a ring the colour of the ground the boxes sit on, which is what a box laid over another cuts out of it. The ring is a box-shadow rather than an outline so it is painted rather than merely reserved, and takes no space. --event-tint was declared by three themes and read by nothing; --event-edge now means how far the edge is mixed rather than a lightness nobody used. A line of month content grew by its own outline, so --day-line grew with it. The browser check measures both rims as contrast ratios against the fill they sit on, in every theme, by painting each computed colour and reading the pixel back -- the mix resolves to oklab, which no amount of parsing rgb strings would have caught. |
||
|
|
ce6a72f604 |
Draw slightly-overlapping events over each other, not beside
Three cases, not two. Two events that do not overlap each take the whole width. Two that start together must be split, because whatever went on top would cover the other's title. But two that overlap while starting far enough apart need neither: the later can be drawn over the earlier, shifted right, with both nearly full width. Splitting those made a day of staggered meetings a row of slivers when it could be a legible cascade. "Far enough apart" is a pixel question wearing a duration's clothes: enough vertical distance for one line of text. The view answers it with one grid increment, which works because the stylesheet keeps an increment at least --slot-min tall and --slot-min above --event-min, one line. So the packer needs no measurements and the rule explains itself -- the grid you chose is the resolution you work at, and two things closer together than one line of it are simultaneous. It follows the setting, too: at a sixty-minute grid a pair half an hour apart really are simultaneous and get split, and at five minutes they do not. Depth is the longest chain a span sits on top of, not a tally of what it covers. Two spans can each cover the same one thing and still cover each other; counting gives them the same depth, so the same position, so the later hides the earlier entirely. Taking the longest chain makes depth strictly increase along every cascade, which makes it a drawing order as well as a position. The shift is then divided by the deepest chain in the pile rather than being a fixed step, so a pile eight deep still fits and nothing is squeezed to nothing. The claim the whole thing rests on is checked against what the browser drew rather than trusted: at every granularity and every density, no event's title is covered by another. The seed carries one of each case so a regression that collapsed them would fail rather than pass quietly. |
||
|
|
99686caa08 |
Place overlapping events side by side
An algorithm with edges, so it lives in the core crate away from any view and is tested against a timetable rather than a browser. v1 wrote it inline in a week-view component, where two of those edges were wrong and neither could be checked without building and squinting. Piles are transitive, not pairwise. v1 gathered the events one event directly overlapped, which is not a pile: 09:00-10:00, 09:30-10:30 and 10:00-11:00 are one, but the ends do not touch each other, so each end was told the pile was two columns wide while the middle was told three. Columns drawn at a half then overlapped columns drawn at a third, and the drawing disagreed with itself about how wide it was. Events widen into the room beside them. Left at a third of the width, a meeting with a free hour either side reports the shape of the pile rather than its own. Packing runs per day column, so a crowded Tuesday does not narrow Wednesday, and each half of a midnight-crossing event competes only with what is actually beside it. A zero-length event is a real thing to put on a calendar and the case a half-open overlap test quietly gets wrong -- nothing can be strictly inside an instant, so it would draw on top of whatever it lands in and hide both. It is treated as the point it names. The property the module exists for is asserted over a deliberately awkward day -- nested, chained, identical, back-to-back, instants, and a long one across it all -- rather than over shapes chosen to pass: nothing that overlaps in time is ever drawn over anything else, and nothing runs past the edge. Chips wrap onto a second line only when there is a second line to land on. Half a line showing through the bottom reads as broken where an ellipsis reads as deliberate, and a chip narrowed to a third of its column is exactly where that bites. The gap between neighbours comes out of the width: an absolutely positioned box with both left and width set ignores margin-right, so a margin there looks right in the source and does nothing on the screen. |
||
|
|
25fe9beb1c |
Size the smallest gradation for landing a drag on
Not for merely rendering. Picking a fine grid and then being unable to hit it is the worse of the two failures, so the floor is a gradation you can put a pointer on and a five-minute grid is allowed to make the day very tall indeed. That is a fair price for asking for one. A quarter-hour grid now gives each gradation its own band rather than sharing the height a whole hour had before. |
||
|
|
8917786220 |
Make the grid granularity mean something, and stop shading weekends
The Grid picker saved a value nothing read. It now rules the hour: six choices, six rulings, hour boundaries drawn heavier than the gradations between them. A menu of six entries where two of them look identical is a menu of two entries with four lies in it. A fine ruling grows the ruler rather than packing twelve lines into an hour nobody can read. `--slot-min` is the smallest gradation still worth drawing, so a five-minute grid gives each hour twelve legible rows and a sixty-minute one leaves the density setting alone. Granularities that do not divide the hour round to ones that do, so no row is ever a different height from its neighbours. Saturday and Sunday are no longer tinted. Which days somebody works is not something a calendar knows, and shading two of them asserts an answer to a question nobody asked it. The tint that remains is drawn before the hour lines rather than after. Painted after, it hid them: today's column, and both weekend columns, had no hours ruled on them at all, which is not a column of a time grid. |
||
|
|
41cbd50755 |
Draw the week and day views on one hour ruler
One component behind both: a day view is a week view with one column. v1 had two, of 1,431 and 900-odd lines, which drifted apart until a fix in one had to be found again in the other. The geometry is fractions of a day, produced by a module tested without a browser and turned into percentages of a column that is 24 hour-tokens tall. So an event's position follows the density tokens, survives any window size, and can be checked without looking at anything. The ruler is wall-clock: twenty-four rows every day, including the ones that are really twenty-three or twenty-five hours long. On a spring-forward day an 01:00-04:00 meeting covers three rows and lasts two hours, and that is the honest drawing — the labels down the side say 01:00 and 04:00, and those are the times on the invitation. An event crossing midnight is drawn on both days, clipped to each, with the cut end left square so it reads as continuing rather than as two unrelated meetings. v1 drew it once and let it run off the bottom of the column into nothing. Long timed events go in the strip above the ruler rather than on it. A three-day conference drawn as three full-height columns would bury every meeting underneath and say nothing a bar across the top does not say better. A 22:00-to-02:00 event is not one of these: it stays on the ruler, where its hours are the point. The grid opens at the working day, or earlier if something is. Opening at midnight means eight rows of nothing above the first thing anybody wants to see; opening at the first event lets last night's spillover decide where every morning starts. Occurrences are now positioned in the viewer's zone. An occurrence keeps the calendar's own spelling of its times, because that is what an edit sends back as RECURRENCE-ID -- so a 09:00 London meeting was being drawn at 09:00 on an Eastern grid, seven hours out. The server resolves the grid coordinates, since it has a zone database and the browser should not need to carry one. The month view was reading the same wrong field. The three things a view draws with -- the occurrence, where it sits, and the colour of the calendar it came from -- now travel as one value instead of parallel vectors zipped back together at each call site. That works until the third one, and then stops working quietly. Bar packing is shared with the month grid rather than written twice. The interesting part, a bar that starts before the row and ends after it, is the part that gets written twice and fixed once. |
||
|
|
df7562e333 |
Draw the month view
Each week is one CSS grid. Row 1 holds the day numbers, the next rows hold multi-day bars placed with `grid-column: a / span b`, and the remainder holds that day's timed events. There is no pixel arithmetic anywhere: the spans and lane numbers come from a pure layout module that is tested without a browser, and every measurement is a density token. v1 could not draw an event across two days in its month view at all, and its week view spent 1,431 lines computing offsets that could only be checked by building, deploying and squinting. Two exclusive ends get undone before anything is drawn. An all-day event's DTEND is the day after it finishes, and a timed event ending at midnight finishes on the previous day; treating either as reaching one day further is the classic month-grid bug. Rows are sized from what they draw rather than from a shared worst case. A month with four empty weeks and two busy ones used to scroll, so you scrolled past the emptiness to reach the part with anything in it. Each row now asks for its own lanes, entries and "+N more" line, and `1fr` above that floor shares out whatever room is left. Trunk was not watching styles/main.css. Only the generated stylesheet is linked from index.html, so Trunk never learned the source was a source: editing it changed nothing and the browser kept serving the previous build. A very quiet way to lose an hour. |
||
|
|
4cf22bbe4d |
Let the dev frontend bind to a chosen address
HOST=0.0.0.0 makes the app reachable from another machine. Only the frontend moves: Trunk proxies /api from the server side, so the backend stays on the loopback and never needs exposing. Prints the reachable addresses from the routing table rather than `hostname -I`, which is empty on some systems, and skips the container bridges, which are not useful to anyone. |
||
|
|
ba90a30e41 |
Add the app shell: sidebar, header, and routing
The view and the focused date live in the URL. That makes the back button work and a week shareable, and gives "which week am I looking at" exactly one answer -- v1 kept the selected date in localStorage *and* in the database with no defined source of truth between them. Date arithmetic is a pure module with its own tests, run natively rather than in a browser. v1 did this inline in a 1,431-line week view where the only way to check it was to build, deploy and look. Thirteen tests, including that a month covers whole weeks and needs six rows when the month does -- v1 shipped "Fix print preview to display all 6 rows for 6-week months" -- and that stepping a month from the 31st lands on the last day of a shorter one instead of skipping February. The sidebar shows each calendar in its own colour, read from the CalDAV server rather than hashed from its path, and hiding one narrows the time-range query rather than filtering in the browser. Two things the browser loop found. chrono::Local traps on wasm without the wasmbind feature, which surfaces as a bare "unreachable" with no message in a release build. And toggling a calendar's visibility silently reordered the sidebar, because the insert wrote position 0 -- position is nullable now, for the same reason colour is: "never arranged" is a different statement from "first". e2e/dev.sh brings the whole stack up seeded with a week of events. It exists partly to record that trunk serve owns dist/: running trunk build against it concurrently rewrites the files without rewriting the integrity hashes in the served index.html, and the browser then blocks its own scripts, which looks like a blank page and is nothing of the sort. |
||
|
|
6eee422421 |
Add the typed API client and the stores behind it
One client covering every endpoint, returning Result<T, ApiError> with a matchable code. v1 had seventeen raw RequestInit call sites across three modules, each with its own header, error and JSON handling, all returning Result<T, String> -- so a 401 and a parse failure arrived looking identical. The types crossing the wire are runway-core's own. Preferences moved there because it is the body of /api/preferences and both ends hold it; a copy on the frontend would be a fifth parallel representation of the kind that lost data at every hop in v1. Moving it surfaced that the schema's DEFAULT for theme and the type's own Default disagreed, which is the same drift in miniature. They now agree on 'system': following the operating system is a better first impression than picking a side. Two stores offered through context, each with one writer, replacing v1's 2,008-line component with 35 use_state hooks prop-drilled through components that only passed them along -- and localStorage acting as a hidden global any component could write to. Preference saves are coalesced, which the browser loop proved is not an optimisation. Changing four settings quickly started four requests whose replies did not come back in order, and the answer to the first change overwrote the third: "opens on Month" was silently still Week after a reload. Only one save is in flight now, and whatever the person has landed on when it returns is what goes next. The screenshot script checks all four settings survive a reload, which is how the bug was found. runway-web is a library as well as a binary, so the client is a public surface rather than dead code awaiting its first caller, and the pure parts have somewhere to be tested from. |
||
|
|
16b1ad9a14 |
Add the frontend shell, the token system, and a login screen
Two axes on <html>: data-theme for colour, data-style for shape and density, independent of each other. v1 shipped 12 themes x 3 layout styles as 36 hand-maintained stylesheets -- 8,300 lines of CSS, 553 custom properties, 116 !important declarations, and the last fifteen commits in the repo were print-preview CSS tweaks. Here a theme is a list of colours and a style is a list of measurements. Every token is semantic: nothing is named --blue-500, because a theme has to be able to change what blue is. The release bundle is 209 KB, 87 KB gzipped. v1's was 2.5 MB, a meaningful share of which was a 638-line dead CalDAV client that kept reqwest, ical and regex in the dependency list. Feature-gating runway-core is what that structural fix buys. One typed API client returning Result<T, ApiError> with a matchable code, replacing v1's seventeen raw RequestInit call sites across three modules, each with its own header, error and JSON handling and all returning Result<T, String>. The session cookie is HttpOnly and this code cannot read it; asking the server who you are is the only way to find out. Trunk proxies /api to the backend so the cookie is same-origin in development exactly as in production, rather than weakening it to SameSite=None for a local convenience. And the browser loop the audit called the biggest change between v1 and v2: e2e/shoot.mjs drives a real Chromium, screenshots every state across both token axes using the actual controls, and reports whatever the console said. It found that wasm-opt needed telling bulk-memory is allowed, and that offering a reference number for a mistyped password suggests a fault at our end. |
||
|
|
2e5bd6ec34 |
Add request tracing and the rules about what must not be logged
v1's backend had 157 println! calls with emoji prefixes, no levels, no filtering and no structure -- and one of them printed "Password length:" from the login handler. There was no way to turn any of it down, and no way to find the lines belonging to one request. Every request now gets an id, echoed in x-request-id and attached to every line logged while handling it. A supplied id is kept, so a reverse proxy correlates with us. Internal errors return that id in the body: "something went wrong" is only useful if it leads somewhere, and the detail stays in the log where it belongs rather than describing the inside of the server to whoever asked. The middleware instruments the inner future rather than tagging its own events with a parent. A test caught the difference: with the parent form, an error raised inside a handler logged outside the span, so the id the client was told to quote led nowhere. Four tests assert what must not appear. A password never reaches the log, nor does its length -- knowing it is nine characters is knowing something. A session token never reaches it either. Query strings are not logged at all, because they carry calendar paths, and the path alone says what happened. And a test greps the source for println!, print!, eprintln! and dbg! in runway-core, runway-caldav and runway-server. main.rs is exempt: genkey printing a key to stdout is its whole job. A rule that only lives in a document gets forgotten. |
||
|
|
3dcd8f76ad |
Add subscribed ICS feeds
The parse is the whole story. v1's importer had two open TODOs -- no RECURRENCE-ID and no VTIMEZONE -- so a correct feed came back looking full of duplicates, because a series master and its modified occurrences share a UID and a SUMMARY and read as a flat list they look like one event repeated. Rather than fix that, v1 added ~500 lines that stripped punctuation from titles, grouped by the result, scored the collisions for "completeness" and threw the losers away. Nothing here compares titles, because nothing looks like a duplicate in the first place. The regression test is two genuinely different meetings called "Stand-up" and "Stand up" at the same moment: both survive. Against the real 1 MB Outlook feed, 103 UIDs expand to 644 occurrences over twenty years with every UID accounted for and no series producing two occurrences at one instant. Freshness has three answers, cheapest first: If-None-Match, then If-Modified-Since, then a hash of the body -- and the hash is not academic, because a published Outlook calendar sends neither validator, so without it every poll would reparse a megabyte to discover nothing changed. v1 had `let etag = None; // TODO`. An unreachable feed serves yesterday's copy rather than blanking the week, and says so. A URL that returns a login page with a 200 is refused at subscribe time, when the person still has the link to hand, and the failed subscription is removed rather than left as a broken row. webcal:// is rewritten, because that is the scheme Outlook and Google hand people. |
||
|
|
c619660adc |
Add the calendars and preferences APIs
Two kinds of setting, split deliberately. A calendar's name and colour are properties of the calendar, so they go to the server with PROPPATCH and every other client agrees -- v1 kept its colours in a JSON blob, disagreed with all of them, and where the server said nothing it hashed the calendar's path to invent one. Visibility and order are how one person arranges their own sidebar and stay local: a household sharing a calendar must not have one member's hidden calendar vanish for everybody. A per-user colour override sits between the two, and the response says which of the three a colour came from so the interface can offer to undo it. PROPPATCH needed the multistatus parser to track per-property failures. A 207 is returned even when nothing changed, with the real answer inside, so without that a refused rename would look like a success and simply not happen. New calendars get a readable slug rather than a uuid, because the path is what other clients show in their settings, with a numeric suffix only when the obvious name is taken. Preferences are validated twice on purpose: the schema is the backstop that makes a bad value impossible by any route, and the handler is the layer that can say why instead of surfacing a constraint violation as a 500. A UTC offset is refused as a time zone with a message explaining that an offset cannot tell January from July. |
||
|
|
c4e8ede28c |
Add the events API
One path with an EditScope on the write verbs. v1 had a second parallel tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating the non-series handlers, dispatching on string literals in 53 places where a typo was a runtime fallthrough. What a scoped edit means to the stored .ics lives in runway-core::series, pure and tested without a server, because that is the subtle part and v1 shipped it with no coverage at all. Editing one occurrence writes an override and no EXDATE: an EXDATE says the occurrence does not happen, an override says it happens differently, and writing both is contradictory. Deleting one writes the EXDATE and removes any override that named it. Splitting a series divides its bound rather than dropping it. Six weekly occurrences split at the third become two plus four, not two plus forever -- the count is what the person asked for and it should survive being cut. Overrides after the split move to the new series; moving a whole series shifts its overrides' RECURRENCE-IDs by the same amount instead of leaving them pointing at occurrences that no longer exist. Every write states a precondition. There is no unconditional path: an update without an ETag is refused, and a stale one is a conflict rather than a silent overwrite. UIDs are minted server-side, because a client-supplied one could collide with and replace an unrelated event. Reads use time-range and fan out across calendars concurrently. Zones that cannot be resolved are reported in the response instead of being rendered as though they were fine. Two tests found real bugs: sub-second timestamps cannot survive iCalendar's one-second resolution, and splitting at the first occurrence was dropping the recurrence rule and quietly turning a series into a single event. |
||
|
|
c7e22f4431 |
Add authentication
Proving who somebody is and starting a session for them are separate operations. login_with_caldav does the first by asking the CalDAV server whether the credentials work; begin_session does the second and knows nothing about how the question was answered. OIDC arrives as a second way to reach begin_session, not as a second scheme threaded through everything -- which is what v1 had, with a JWT for most of the app and a separate SQLite session_token used only by the preferences API. The token lives in an HttpOnly cookie and nowhere else. v1 kept a JWT and the CalDAV password in localStorage, readable by any script on the origin, and re-sent the password in a header on every request. Here the password never leaves the server: it is encrypted with XChaCha20-Poly1305 and caldav_for is the only path back, handing out a client rather than a credential. Failed decryption is an error, not a subtly wrong password -- the AEAD tag is checked, so a tampered row surfaces here instead of as a mysterious CalDAV rejection later. A wrong password and an unreachable server stay distinct, because telling somebody their password is wrong when the server is down sends them to reset one that was fine. Errors carry a stable code alongside their message, so a client can branch on them. Internal ones say nothing about the inside of the server; the detail goes to the log. Tests go through router(), the same function main calls -- v1's suite rebuilt the route table and tested a copy until it stopped compiling. Skipping is now loud: a skipped test reports "ok", so run.sh sets RUNWAY_REQUIRE_CALDAV=1 and not running becomes a failure. |
||
|
|
82d05dc67a |
Add the database schema and repository layer
The per-calendar JSON blob is gone. v1 kept every calendar's colour, every visibility toggle and a custom palette inside one calendar_colors TEXT column, so hiding one calendar rewrote the whole document -- which is where "Fix calendar visibility preservation during event updates" came from. Those are rows now, and set_visible touches visibility alone. Foreign keys are switched on. v1 declared external_calendars.user_id as INTEGER against a TEXT users.id; SQLite enforces neither the type nor the constraint unless asked, so it was decorative and could never match. Session tokens are stored as SHA-256, never in the clear, so a copy of the database cannot be used to impersonate anyone. The CalDAV password is the one secret that cannot be hashed -- it has to be replayed to the server -- so it gets an encrypted column with the algorithm recorded alongside, and one way in and one way out instead of v1's eight localStorage reads. Preferences are one column each with CHECK constraints, so a bad view or a nonsense time increment is refused whatever route it arrives by. A NULL display timezone means "follow the browser", which is a state v1 could not express -- as with a NULL calendar colour meaning "defer to the server", which is why it hashed paths to invent one. Feed caching stores a content hash beside the ETag, because a published Outlook feed sends neither ETag nor Last-Modified and staleness has to be detectable anyway. Thirty tests against real in-memory SQLite, not mocks. One caught that create() returned nanosecond timestamps while the column stores microseconds, so a session never compared equal to itself read back. |
||
|
|
c197e08af0 |
Default the CLI to the system time zone
It shipped with America/Denver hardcoded, taken from what dominates the historical data on the server. That data is old; the reader has moved. A zone belongs to whoever is looking at the calendar, and baking one in is the same mistake as v1's offset-instead-of-zone in miniature -- it looks right until the reader is somewhere else. Falls back to UTC rather than to a populated guess: an obviously neutral wrong answer gets noticed, a plausible one does not. |
||
|
|
f8e4a497fa |
Add the CalDAV client and a CLI to drive it
Discovery is the three PROPFINDs RFC 4791 describes rather than a walk through likely URLs. Queries use time-range, which v1 never did -- it fetched whole calendars and filtered in the browser on every view change. Every write states a precondition, so a stale ETag produces a Conflict a caller can act on instead of silently destroying somebody's edit. XML goes through quick-xml with namespace resolution. v1 matched prefixes with six regexes tried in sequence and recompiled inside the loop; there is a fixture here that is the same document under different prefixes, and it parses identically. Protocol parsing is split from transport so it can be tested against responses recorded from a real Baikal -- including the second propstat carrying 404s, which is what makes "this calendar has no colour" different from "this calendar has an empty colour". Live tests run against a real server, never a mock. tests/baikal/run.sh starts a container, walks Baikal's install wizard, and runs them; each test builds and destroys its own collection, so pointing it at a real server touches nothing that was already there. They cover discovery, round-trip, stale-ETag conflict, duplicate create, delete, time-range filtering, a series returned whole with its override, and writing every synthetic golden fixture to the server and reading it back. libdav was evaluated first, as planned. Not adopted: its HttpClient trait is defined over hyper::body::Incoming, so using it means replacing reqwest everywhere, plus a DNS resolver for service discovery we do not do and a second XML parser. Its precondition design is where Precondition's shape comes from. Reasons are recorded in the crate docs. |
||
|
|
689b78154b |
Add recurrence expansion
Rules come from the rrule crate. What is ours is the layer above it:
reconciling a series master with the RECURRENCE-ID overrides that replace
individual occurrences, which is where the subtlety actually lives. An
override suppresses the occurrence it names, and an override whose master
falls outside the window is still emitted -- a published feed truncates
series at its edge, and those are real events.
Occurrences know their own RECURRENCE-ID. v1 encoded instance identity as
a "{uid}-{timestamp}" string and split it apart again in the view layer.
TZID resolution is a ladder: IANA, then Windows zone names through a CLDR
table generated from windowsZones.xml, then an assumption that is reported
rather than hidden. Exchange writes "Pacific Standard Time" and every zone
in the real feed maps. Deriving a VTIMEZONE's own offsets is not built --
nothing in the corpus needs it, and the definitions are already carried
should that change.
Daylight saving is settled explicitly rather than by unwrap. An ambiguous
time takes the earlier reading; a time inside a spring-forward gap slides
past it by the gap's own length, so 02:15 and 02:45 stay distinct and stay
in order instead of both snapping to 03:00.
Tested against known-good outputs: DST both directions, leap day, nth
weekday, BYMONTHDAY on short months, COUNT against UNTIL, EXDATE against
an override, and the real Outlook feed -- where the assertion is that no
series ever yields two occurrences at one instant, which is what the old
importer's title-matching heuristics were standing in for.
|
||
|
|
7043a151f6 |
Add the iCalendar round-trip
Parsing goes through icalendar's low-level parser, which keeps properties in order and keeps repeated ones. Writing is ours: that crate's writer escapes a whole property value as text, so CATEGORIES:Work,Personal would go out as one category named "Work,Personal" to every other client. Anything the model does not interpret is carried rather than dropped -- X-MOZ-LASTACK, X-EVOLUTION-ALARM-UID, ACKNOWLEDGED, the X-MICROSOFT-CDO set, unrecognised ATTENDEE parameters, and whole VTODO/VJOURNAL components. A calendar has several clients writing to it and this one is not the authority on which properties matter. VTIMEZONE is modelled properly, and TZID is stored exactly as written: Exchange names its zones "Pacific Standard Time", which no IANA lookup resolves, and normalising at parse time would make the document unrepresentable. Mapping to a real zone belongs at the point of use. Tested against a golden corpus captured from the live Baikal (seven producing clients over five years) and a published Outlook feed, scrubbed of private content with the structure left byte-for-byte. Eight hand-written fixtures cover what neither server had: DURATION, floating times, RDATE, DST boundaries, leap day, and the full escape set. The contract is that parse -> write -> parse is stable, plus a check that no property name loses occurrences across the trip, since a parser that dropped ATTENDEE entirely would round-trip perfectly and still be wrong. |
||
|
|
52cd5a961d |
Add the RFC 5545 domain model
VEvent and friends, transcribed from the previous calendar-models crate and tightened so that the states which caused its timezone and recurrence bugs cannot be represented. The substantive changes from v1: CalendarDateTime is an enum over the four forms RFC 5545 actually admits (date, floating, UTC, zoned) instead of a NaiveDateTime plus a loose Option<String> zone plus an all_day flag that could all disagree. A zoned value carries an IANA identifier, never a UTC offset -- an offset cannot tell standard time from daylight time, which is why recurring events drifted an hour across DST. EventEnd is an enum, because DTEND and DURATION are mutually exclusive. Priority validates 0-9 on construction and on deserialisation. EditScope replaces dispatch on strings like "this_and_future", which appeared 53 times and turned typos into silent fallthrough. CalendarObject models a CalDAV resource as it really is: one UID, one master, N RECURRENCE-ID overrides. v1 flattened this to a bare event list, which made overrides look like duplicates and motivated ~500 lines of title-matching heuristics that silently discarded events. Dropped VJournal, VFreeBusy, VTimeZone, TodoStatus, FreeBusyType and Period: defined but never used. 28 tests cover serde round-trips, the exact JSON shape (the model is the wire format, so changing it should be deliberate), duration fallbacks, validation boundaries and master/override separation. uuid needs an explicit entropy source on wasm; without it the frontend cannot compile the shared model. Verified that the default feature set pulls in neither icalendar, rrule, chrono-tz, quick-xml nor reqwest. |
||
|
|
bf63024711 |
Set up workspace skeleton
Five crates: runway-core (pure domain), runway-caldav (protocol), runway-server (axum), runway-web (leptos), runway-cli (smoke tool). runway-core is feature-gated into model/ical/recurrence so the frontend can depend on the shared types without pulling icalendar and rrule into the WASM bundle. The previous iteration shipped reqwest, ical and regex to the browser for a dead module; the feature split makes that mistake structurally hard to repeat. Guardrails are compiler- and CI-enforced rather than aspirational: workspace lints deny unwrap_used/expect_used, dead_code and unsafe_code, clippy.toml caps function length and arity, deny.toml pins licences. Toolchain is pinned per-project so the machine-wide default is untouched. Cargo.lock is committed this time. docs/legacy-audit.md carries the marked-up feature decisions and is the spec for the rewrite. |