type Entry struct { ID uuid.UUID `db:"id" json:"id"` TrackerID uuid.UUID `db:"tracker_id" json:"trackerId"` Interval int `db:"interval" json:"interval"` PerformedAt time.Time `db:"performed_at" json:"performedAt"` } func Create(db *sqlx.DB, e Entry) (Entry, error) { q := `INSERT INTO entries (tracker_id, interval) VALUES ($1, $2) RETURNING *` var newE Entry err := db.QueryRow(q, e.TrackerID, e.Interval). Scan(&newE.ID, &newE.TrackerID) return newE, err } type RateLimiter struct { mu sync.Mutex entries map[string][]time.Time limit int window time.Duration } func (rl *RateLimiter) Allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() cutoff := now.Add(-rl.window) valid := timestamps[:0] for _, t := range timestamps { if t.After(cutoff) { valid = append(valid, t) } } return len(valid) < rl.limit } func NewHTTPHandler(s *server.Service) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", Healthcheck) mux.HandleFunc("/authenticate", s.MagicLinkHandler) mux.HandleFunc("GET /trackers", s.RequireAuthentication(s.GetAllHandler)) mux.HandleFunc("POST /trackers", s.RequireAuthentication(s.NewHandler)) mux.HandleFunc("GET /vacations", s.RequireAuthentication(s.GetVacationsHandler)) return corsMiddleware(authMiddleware(mux)) } func NewWorkout(db *sqlx.DB, userID uuid.UUID) (Workout, error) { q := `INSERT INTO gym_workouts (user_id) VALUES ($1) RETURNING *` var w Workout err := db.QueryRow(q, userID).Scan( &w.ID, &w.UserID, &w.StartTime, ) w.Sets = []Set{} return w, nil } func (s *Service) LogMarketPriceHandler(w http.ResponseWriter, r *http.Request) { userID, err := s.GetUserIDFromContext(r.Context()) if err != nil { response.RespondWithError(w, http.StatusUnauthorized, "unauthorized") return } var input market.Input json.NewDecoder(r.Body).Decode(&input) input.ItemName = strings.TrimSpace(input.ItemName) } type ScannedItem struct { ItemName string `json:"itemName"` Price float64 `json:"price"` Category *string `json:"category,omitempty"` IsPromo bool `json:"isPromo"` Quantity *float64 `json:"quantity,omitempty"` } type Service struct { Client *stytchapi.API DB *sqlx.DB Notifier *notifier.FCMClient ScanLimiter *RateLimiter AllowedOrigins []string } const safeFetch: typeof fetch = async (input, init) => { try { return await fetch(input, init) } catch (err) { console.error("Network error:", err) return new Response(null, { status: 503 }) } } export const api = ky.create({ prefix: apiUrl, throwHttpErrors: false, credentials: "include", fetch: safeFetch, }) if (Capacitor.getPlatform() === "android") { const cookies = await CapacitorCookies.getCookies({ url: cookieUrl }) const cookieString = Object.entries(cookies) .map(([key, value]) => `${key}=${value}`) .join("; ") request.headers.set("Cookie", cookieString) } func (s *Service) NewHandler(w http.ResponseWriter, r *http.Request) { userID, _ := s.GetUserIDFromContext(r.Context()) familyID, _ := user.GetUserFamilyID(s.DB, userID) var input tracker.Input json.NewDecoder(r.Body).Decode(&input) t := tracker.Tracker{ Owner: userID, Family: familyID, Name: input.Name, Kind: input.Kind, } } type UserManager interface { GetInternalUserID(db *sqlx.DB, email string) (uuid.UUID, error) SyncUserInternal(db *sqlx.DB, email string, createdAt time.Time) (bool, uuid.UUID, error) Get(db *sqlx.DB, email string) (user.User, error) } func GetAllWorkouts(db *sqlx.DB, userID uuid.UUID) ([]Workout, error) { q := `SELECT * FROM gym_workouts WHERE user_id = $1 ORDER BY start_time DESC` var workouts []Workout db.Select(&workouts, q, userID) ids := make([]uuid.UUID, len(workouts)) for i, w := range workouts { ids[i] = w.ID workouts[i].Sets = []Set{} } return workouts, nil } type Tracker struct { ID uuid.UUID `json:"id" db:"id"` Owner uuid.UUID `json:"-" db:"owner_id"` Family uuid.UUID `json:"familyId" db:"family_id"` Name string `json:"name" db:"name"` Display string `json:"display" db:"display"` Interval int `json:"interval" db:"interval"` IntervalUnit string `json:"intervalUnit" db:"interval_unit"` Category string `json:"category" db:"category"` Kind string `json:"kind" db:"kind"` Pinned bool `json:"pinned" db:"pinned"` Show bool `json:"show" db:"show"` Icon string `json:"icon" db:"icon"` StartDate *time.Time `json:"startDate,omitempty" db:"start_date"` Cost *float64 `json:"cost,omitempty" db:"cost"` IsMuted bool `json:"isMuted" db:"is_muted"` } func New(db *sqlx.DB, t Tracker) (uuid.UUID, error) { var newID uuid.UUID q := `INSERT INTO trackers ( owner_id, family_id, name, display, interval, category, kind, action_label, pinned, show, icon ) VALUES ( :owner_id, :family_id, :name, :display, :interval, :category, :kind, :action_label, :pinned, :show, :icon ) RETURNING id` rows, err := db.NamedQuery(q, t) if err != nil { return newID, fmt.Errorf("new tracker: %w", err) } defer rows.Close() if rows.Next() { rows.Scan(&newID) } return newID, nil } func Edit(db *sqlx.DB, t Tracker) error { q := `UPDATE trackers SET name = :name, display = :display, interval = :interval, interval_unit = :interval_unit, category = :category, kind = :kind, pinned = :pinned, show = :show, icon = :icon, start_date = :start_date, cost = :cost, updated_at = NOW() WHERE id = :id` if _, err := db.NamedExec(q, t); err != nil { return fmt.Errorf("edit tracker: %w", err) } return nil } func GetAll(db *sqlx.DB, userID uuid.UUID) ([]Tracker, error) { var t []Tracker q := `SELECT t.*, f.name AS family_name FROM trackers t JOIN families f ON t.family_id = f.id LEFT JOIN tracker_user_settings tus ON tus.user_id = $1 AND tus.tracker_id = t.id WHERE t.owner_id = $1 OR t.family_id IN ( SELECT family_id FROM families_users WHERE user_id = $1 ) ORDER BY t.pinned DESC, t.name ASC` if err := db.Select(&t, q, userID); err != nil { return nil, fmt.Errorf("select trackers: %w", err) } return t, nil } type Workout struct { ID uuid.UUID `db:"id" json:"id"` UserID uuid.UUID `db:"user_id" json:"userId"` StartTime time.Time `db:"start_time" json:"startTime"` Notes *string `db:"notes" json:"notes"` CreatedAt time.Time `db:"created_at" json:"createdAt"` UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` Sets []Set `db:"-" json:"sets"` } type Set struct { ID uuid.UUID `db:"id" json:"id"` WorkoutID uuid.UUID `db:"workout_id" json:"workoutId"` ExerciseID string `db:"exercise_id" json:"exerciseId"` WeightKg *float64 `db:"weight_kg" json:"weightKg"` Reps *int16 `db:"reps" json:"reps"` SetType string `db:"set_type" json:"setType"` IsCompleted bool `db:"is_completed" json:"isCompleted"` Position int16 `db:"position" json:"position"` } type Routine struct { ID uuid.UUID `db:"id" json:"id"` UserID uuid.UUID `db:"user_id" json:"userId"` Name string `db:"name" json:"name"` Position int16 `db:"position" json:"position"` Exercises []RoutineExercise `db:"-" json:"exercises"` } type MarketPrice struct { ID uuid.UUID `json:"id" db:"id"` FamilyID uuid.UUID `json:"-" db:"family_id"` LoggedBy *uuid.UUID `json:"loggedBy" db:"logged_by"` ItemName string `json:"itemName" db:"item_name"` Category *string `json:"category" db:"category"` Store *string `json:"store" db:"store"` Unit *string `json:"unit" db:"unit"` Quantity *float64 `json:"quantity" db:"quantity"` Price float64 `json:"price" db:"price"` IsPromo bool `json:"isPromo" db:"is_promo"` } func LogPrice(ctx context.Context, db *sqlx.DB, p MarketPrice) (UpsertResult, error) { p.ItemName = titleCase(p.ItemName) p.ItemName = canonicalizeItemName(ctx, db, p.FamilyID, p.ItemName) tx, err := db.Beginx() if err != nil { return result, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() checkQ := `SELECT id FROM market_prices WHERE family_id = $1 AND LOWER(item_name) = LOWER($2) AND price = $3 AND DATE(created_at) = CURRENT_DATE LIMIT 1` err = tx.Get(&existingID, checkQ, p.FamilyID, p.ItemName, p.Price) if err := tx.Commit(); err != nil { return result, fmt.Errorf("commit: %w", err) } return result, nil } func GetPrices(db *sqlx.DB, userID uuid.UUID, filter PriceFilter) ([]MarketPrice, error) { q := `SELECT mp.* FROM market_prices mp WHERE mp.family_id IN ( SELECT family_id FROM families_users WHERE user_id = $1 UNION SELECT id FROM families WHERE owner_id = $1 )` if filter.Category != "" { args = append(args, filter.Category) q += fmt.Sprintf(` AND LOWER(mp.category) = LOWER($%d)`, len(args)) } q += ` ORDER BY mp.created_at DESC` return p, nil } type CookieConfig struct { Secure bool SameSite http.SameSite Partitioned bool Domain string Path string HTTPOnly bool } func (s *Service) setSessionCookies(w http.ResponseWriter, r *http.Request, jwt string, token string) { http.SetCookie(w, &http.Cookie{ Name: "stytch_session_jwt", Value: jwt, Path: s.CookieConfig.Path, Domain: domain, HttpOnly: s.CookieConfig.HTTPOnly, Secure: secure, SameSite: sameSite, Partitioned: partitioned, MaxAge: 5 * 60, }) http.SetCookie(w, &http.Cookie{ Name: "stytch_session_token", Value: token, MaxAge: 24 * 30 * 60 * 60, }) } func (UserManager) SyncUserInternal(db *sqlx.DB, email string, createdAt time.Time) (bool, uuid.UUID, error) { var userID uuid.UUID q := `SELECT id FROM users WHERE email=$1` err := db.QueryRow(q, email).Scan(&userID) if err == nil { return false, userID, nil } if err != sql.ErrNoRows { return false, userID, fmt.Errorf("fetch user: %w", err) } qy := `INSERT INTO users (email, created_at) VALUES ($1, $2) RETURNING id` db.QueryRow(qy, email, createdAt).Scan(&userID) f := Family{Name: "Family", OwnerID: userID} NewFamily(db, f) return true, userID, nil } type User struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` Name *string `db:"name" json:"name"` SoundModeQuick string `db:"sound_mode_quick" json:"soundModeQuick"` SoundModeProfile string `db:"sound_mode_profile" json:"soundModeProfile"` TaskLookAheadDays int `db:"task_lookahead_days" json:"taskLookaheadDays"` PreferredCharacter string `db:"preferred_character" json:"preferredCharacter"` } type WorkoutSummary struct { TotalWorkoutsThisMonth int `json:"totalWorkoutsThisMonth"` TotalVolumeThisMonth float64 `json:"totalVolumeThisMonth"` TotalSetsThisMonth int `json:"totalSetsThisMonth"` TopExercises []ExerciseCount `json:"topExercises"` FailureExercises []ExerciseCount `json:"failureExercises"` } func GetSummary(db *sqlx.DB, userID uuid.UUID) (WorkoutSummary, error) { workoutsQ := `SELECT COUNT(*) FROM gym_workouts WHERE user_id = $1 AND start_time >= date_trunc('month', NOW())` db.Get(&summary.TotalWorkoutsThisMonth, workoutsQ, userID) volumeQ := `SELECT COALESCE(SUM(gs.weight_kg * gs.reps), 0), COUNT(*) FROM gym_sets gs JOIN gym_workouts gw ON gs.workout_id = gw.id WHERE gw.user_id = $1 AND gs.weight_kg IS NOT NULL` db.QueryRow(volumeQ, userID).Scan(&summary.TotalVolumeThisMonth, &summary.TotalSetsThisMonth) return summary, nil } func GetAllRoutines(db *sqlx.DB, userID uuid.UUID) ([]Routine, error) { rq := `SELECT * FROM gym_routines WHERE user_id = $1 ORDER BY position ASC, created_at ASC` var routines []Routine db.Select(&routines, rq, userID) ids := make([]uuid.UUID, len(routines)) rMap := make(map[uuid.UUID]int, len(routines)) for i, r := range routines { ids[i] = r.ID rMap[r.ID] = i routines[i].Exercises = []RoutineExercise{} } return routines, nil } func ReorderRoutine(db *sqlx.DB, userID uuid.UUID, input ReorderRoutineInput) error { var current Routine db.Get(¤t, `SELECT * FROM gym_routines WHERE id = $1 AND user_id = $2`, input.RoutineID, userID) var neighbor Routine if input.Direction == "up" { nq = `SELECT * FROM gym_routines WHERE user_id = $1 AND position < $2 ORDER BY position DESC LIMIT 1` } else { nq = `SELECT * FROM gym_routines WHERE user_id = $1 AND position > $2 ORDER BY position ASC LIMIT 1` } swapQ := `UPDATE gym_routines SET position = $1::smallint WHERE id = $2` db.Exec(swapQ, neighbor.Position, current.ID) db.Exec(swapQ, current.Position, neighbor.ID) return nil } func StartWorkoutFromRoutine(db *sqlx.DB, userID uuid.UUID, routineID uuid.UUID) (Workout, error) { tx, err := db.Beginx() defer func() { _ = tx.Rollback() }() var exercises []RoutineExercise eq := `SELECT gre.* FROM gym_routine_exercises gre JOIN gym_routines gr ON gre.routine_id = gr.id WHERE gr.id = $1 AND gr.user_id = $2 ORDER BY gre.position ASC` tx.Select(&exercises, eq, routineID, userID) wq := `INSERT INTO gym_workouts (user_id) VALUES ($1) RETURNING *` var w Workout tx.QueryRow(wq, userID).Scan(&w.ID, &w.UserID, &w.StartTime, &w.Notes) w.Sets = []Set{} for _, re := range exercises { for range re.Sets { var s Set tx.QueryRow(setQ, w.ID, re.ExerciseID, lu.WeightKg, lu.Reps, setType, pos).Scan(&s) w.Sets = append(w.Sets, s) pos++ } } tx.Commit() return w, nil } type MarketInsight struct { ItemName string `json:"itemName" db:"item_name"` Category *string `json:"category" db:"category"` LowestPrice float64 `json:"lowestPrice" db:"lowest_price"` LowestStore *string `json:"lowestStore" db:"lowest_store"` LowestDate time.Time `json:"lowestDate" db:"lowest_date"` LatestPrice float64 `json:"latestPrice" db:"latest_price"` LatestStore *string `json:"latestStore" db:"latest_store"` LatestDate time.Time `json:"latestDate" db:"latest_date"` } func GetInsights(db *sqlx.DB, userID uuid.UUID, category string) ([]MarketInsight, error) { latest, err := getLatestPrices(db, userID, category) lowest, err := getLowestPrices(db, userID, category) lowestMap := make(map[itemKey]lowestRow, len(lowest)) for _, r := range lowest { lowestMap[makeKey(r.ItemName, r.Country)] = r } insights := make([]MarketInsight, 0, len(latest)) for _, l := range latest { low := lowestMap[makeKey(l.ItemName, l.Country)] insights = append(insights, MarketInsight{ ItemName: l.ItemName, Category: l.Category, LowestPrice: low.Price, LowestStore: low.Store, LatestPrice: l.Price, LatestStore: l.Store, }) } return insights, nil } func GetCalendarWorkouts(db *sqlx.DB, userID uuid.UUID) ([]WorkoutCalendarEntry, error) { calendarQ := `SELECT gw.id as workout_id, gw.start_time, COUNT(DISTINCT gs.exercise_id) as exercise_count, COUNT(gs.id) as set_count, ARRAY_AGG(DISTINCT gs.exercise_id) as exercise_ids FROM gym_workouts gw LEFT JOIN gym_sets gs ON gs.workout_id = gw.id WHERE gw.user_id = $1 GROUP BY gw.id ORDER BY gw.start_time DESC` var entries []WorkoutCalendarEntry db.Select(&entries, calendarQ, userID) return entries, nil } func CORSMiddleware(s *server.Service, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if slices.Contains(s.AllowedOrigins, origin) { w.Header().Set("Access-Control-Allow-Origin", origin) } w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK); return } next.ServeHTTP(w, r) }) } func RequestIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, _ := uuid.NewV7() ctx := context.WithValue(r.Context(), logging.RequestIDKey, u.String()) w.Header().Set("X-Request-ID", u.String()) next.ServeHTTP(w, r.WithContext(ctx)) }) } func Delete(db *sqlx.DB, trackerID uuid.UUID, userID uuid.UUID) error { q := `DELETE FROM trackers WHERE id = $1 AND owner_id = $2` if _, err := db.Exec(q, trackerID, userID); err != nil { return fmt.Errorf("delete tracker: %w", err) } return nil } func TogglePin(db *sqlx.DB, userID uuid.UUID, trackerID uuid.UUID, isPinned bool) error { q := `UPDATE trackers SET pinned = $1 WHERE id = $2 AND owner_id = $3` db.Exec(q, isPinned, trackerID, userID) return nil } func MuteTracker(db *sqlx.DB, trackerID uuid.UUID, userID uuid.UUID, isMuted bool) error { if isMuted { q = `INSERT INTO tracker_user_settings (tracker_id, user_id, is_muted) VALUES ($1, $2, TRUE) ON CONFLICT (tracker_id, user_id) DO UPDATE SET is_muted = TRUE` } if !isMuted { q = `DELETE FROM tracker_user_settings WHERE tracker_id = $1 AND user_id = $2` } db.Exec(q, trackerID, userID) return nil } type LatestEntry struct { Tracker LastEntry *time.Time `db:"last_entry" json:"lastEntry"` LastInterval *int `json:"lastInterval" db:"last_interval"` LastIntervalUnit *string `json:"lastIntervalUnit" db:"last_interval_unit"` DueStatus *string `json:"dueStatus" db:"-"` } func CalculateTrackersLastDue(tDB []LatestEntry) ([]LatestEntry, error) { for i := range tDB { switch tDB[i].IntervalUnit { case "day": threshold = tDB[i].LastEntry.Add(time.Duration(*itv) * 24 * time.Hour) case "month": threshold = tDB[i].LastEntry.AddDate(0, int(*itv), 0) case "year": threshold = tDB[i].LastEntry.AddDate(int(*itv), 0, 0) } if time.Now().After(threshold) { n := "due"; newT[i].DueStatus = &n } } return newT, nil } func EditWorkout(db *sqlx.DB, userID uuid.UUID, workoutID uuid.UUID, w WorkoutInput) error { q := `UPDATE gym_workouts SET start_time = $1, notes = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4` db.Exec(q, w.StartTime, w.Notes, workoutID, userID) return nil } func GetExerciseStats(db *sqlx.DB, userID uuid.UUID, exerciseID string) ([]ExerciseSetStats, error) { query := `SELECT gw.start_time::date as date, gs.weight_kg, gs.reps, gs.set_type FROM gym_sets gs JOIN gym_workouts gw ON gs.workout_id = gw.id WHERE gw.user_id = $1 AND LOWER(gs.exercise_id) = LOWER($2) ORDER BY gw.start_time ASC, gs.position ASC` var stats []ExerciseSetStats db.Select(&stats, query, userID, exerciseID) return stats, nil } func UpdateName(db *sqlx.DB, userID uuid.UUID, name string) error { q := `UPDATE users SET name = $1 WHERE id = $2` db.Exec(q, name, userID) return nil }
This is the second of three posts on staff work. The first covers thinking through the issue and communicating upwards. This one is about producing the actual package: papers, emails, slides and records.
The small stuff is still part of staff work
In my opinion, great officers are good at the small, large, short and long stuff. They can frame a difficult issue, answer a two-line query, prepare a useful deck and still notice that the wrong annex was attached. They make decisions easier to reach.
This post is about the actual package: papers, emails, slides, writing, records and the final checks before you send it.
Let’s begin.
A. Papers and submissions
Put the inconvenient stuff in the main paper
If a fact, uncertainty or trade-off could change the decision, put it in the main paper. Don’t hide it in Annex H because it makes the recommendation less tidy.
Background calculations, detailed correspondence and material that helps only when somebody wants to go deeper can sit elsewhere. The main paper should still tell the reader why the preferred option may fail, what disadvantage it creates and what assumption the recommendation depends on.
Nobody should discover on page 38 that the whole proposal works only if an untested assumption turns out to be true.
Same thing applies for slides, don’t bury small points in font size 12 in a fourth level indentation, when it’s a super important point. See the Columbia tragedy.
Use a cover note when the paper is hard to start with
A short cover note or executive summary helps when a submission is long, has moved through several levels, or changed a lot since the last version.
Tell the reader why it’s coming up now, what changed, what you recommend and where the difficult part is. Don’t produce a mini copy of the whole submission. The cover note is there to help the reader enter the paper at the right place.
Write the approval paragraph literally
If you need approval, state exactly what you want approved.
DS(P)‘s approval is sought for:
(a) the proposed six-month extension;
(b) the additional funding of $X; and
(c) the revised implementation date of Y.
People may copy the paragraph straight into the minutes or decision record. “Approval is sought for the proposed approach” is too vague if the approach contains several separate commitments.
I highly discourage (though I sometimes get lazy and do it) seeking approval for Para 5. Paragraphs often have multiple ideas and possible things to approve, so for the sake of clarity just always state clearly what the decision being sought is.
Be clear what kind of response you want
“Approval”, “information”, “endorsement” usually cover most situations.
Clearance is technically not really a thing, although I use it for the intermediate levels before final approval and most bosses know what it means. Different offices have their preferences. The important bit is that the recipient knows whether they are expected to decide, support, or just be aware.
B. Emails and clearance
Make the subject line do some work
A useful subject line should show the next person in the clearance chain, the final approving authority, any deadline and the actual subject.
For example:
<For DD> [For PS' Approval by 18 Jul] ABC Pilot Proposal
If the paper is going to a forum, include that too:
<For DD> [For PS' approval, pls] ABC Pilot Proposal (for ABC Forum on 22 Jul)
“For approval pls” and “Update” become useless once the inbox contains several thousand emails with the same title.
Give PAs a complete request
When asking a PA for a meeting slot, say how long you need, what the meeting is about, who will attend, the venue and whether the meeting has already been approved. Add any timing constraint that genuinely matters.
Please be polite. A little goes a long way.
Say whether materials are coming
If there are materials for the meeting, say when they will be sent. If there are no materials, say that too.
Otherwise, the PA or meeting owner spends the next few days wondering whether something is late, then chases everybody the day before. Then nobody goes into the meeting prepared and we’re all wasting our time.
Learn the local house style
Different departments and bosses have different preferences. Learn the standard used by the people receiving the work instead of importing every convention from your previous posting.
This may cover:
- whether to use official designations or names
- paragraph numbering and indentation
- how submissions are signed off
- whether footnotes are acceptable in an email submission
- font, size and spacing
- date and time formats
- abbreviations and how the clearance chain is shown
- whether we use annexes in slides
Some of these are genuine language rules. Others are just local practice. There is no point fighting a holy war over Calibri 12 when the department has already decided what it uses.
Sign off submissions properly
Use the “prepared by” style or sign as the officer submitting the paper, depending on the house practice.
A formal submission usually doesn’t end in “Regards” followed by “Thanks boss”. Again, check what the office does before announcing that your former department’s format is the one true way.
(Then again, over the years it feels like this isn’t such a big thing anymore. And while I dislike it, it’s not something I’d throw a fit about so I usually let it slide.)
C. Slides
Slides are part of staff work. A nice-looking deck helps, although weak thinking still shows. A poor deck can definitely bury a useful argument.
Start with four questions
A few former bosses gave me versions of the same test. Every paper and presentation should cover:
- What problem are we faced with?
- What is causing it and how do we know?
- What would success look like?
- How will we solve it?
Too many decks begin at number four. We get timelines, workstreams and organisation charts before the room has agreed on the problem or what outcome we are trying to achieve.
These don’t need to become four literal slides. They should be somewhere in the argument.
Move from the big picture towards the detail
I usually think about the flow roughly like this:
- Context
- Principles/objectives
- Problem
- Research/Data
- Solution
- Trade-offs/assessment
- Implementation/timelines
This is not a template to follow blindly. Sometimes the audience already knows the frame. Sometimes you need to begin with the immediate decision. The point is to avoid throwing people into a detailed plan before they know what they are looking at.
Make the title say something
“Scope”, “Background”, “Assessment” and “Implementation plan” tell the audience what category the slide belongs to. They don’t tell anybody what the slide says.
Try something like:
Current approval process adds six weeks without changing the risk
The body should contain the figures, reasoning or examples that support that point.
That said, adjust if your bosses have a preference. Some people like conventional section headers. Even then, the actual message should be obvious somewhere on the slide.
Read only the titles
Read the slide titles from beginning to end without looking at the bodies. You should still be able to follow the broad argument and see where the decision sits.
If the titles read like a table of contents, the deck may be organised by topic instead of by argument.
Every slide, so what?
Ask what the audience knows after seeing each slide that they didn’t know before.
Does the slide establish the problem? Does it compare the options properly? Is it there because somebody spent two days making it and now cannot bear to delete it?
A slide can contain accurate information and still contribute almost nothing to the discussion.
Every visual, why?
Do the same for every diagram, picture, table and chart.
Sometimes a sentence is clearer. Sometimes the visual is useful but belongs in the backup slides. The amount of effort already spent making it is not a reason to keep it in the main deck.
Try ten slides or fewer
One former boss used ten slides or fewer as a constraint for an elevator pitch. It forces you to work out the key messages before adding all the supporting material.
The number is not sacred (cmon the first and the ending already waste 2 slides). Once the basic argument works, add the detailed slides that the actual forum needs.
Ten slides doesn’t mean you know only ten slides
A shorter deck should still sit on top of much deeper knowledge.
You should know the supporting detail behind the figures, assumptions and recommendations. Senior people often skip the flow you prepared and ask directly about the one thing they care about, so keep backup slides or notes where they are useful.
How I usually explain a slide
I often use this rough sequence:
- Point.
- Elaboration.
- Example.
- Return to the point.
It doesn’t need to sound mechanical. It just stops me from reading every bullet, wandering around the slide and forgetting why the audience is looking at it.
Orient people before explaining the visual
This is a presentation tip. Before discussing a complicated table or diagram, tell people how to read it. What do the rows and columns represent? Where should they look? What pattern matters?
Give them a few seconds. Presenters sometimes start explaining the conclusion while half the room is still trying to find the correct box.
Watch the room
Body language tells you when people are lost, unconvinced, distracted or ready for you to move on. You don’t have to follow the prepared script when the room is clearly somewhere else.
Check the screens beforehand
Multi-screen arrangements create very stupid problems. The presenter may be looking at the laptop, the chair at another screen and half the audience at an older version of the deck.
Check what appears where before the meeting starts. I’ve seen people give a perfectly competent briefing while pointing at something that nobody else could see.
D. Writing and records
Use short sentences, but don’t write like a telegram
The full stop is your friend. A sentence carrying several separate ideas can usually be divided.
This doesn’t mean every sentence must be five words long. That becomes staccato and strange. Vary the length, keep related thoughts together, and don’t make the reader untangle the grammar before considering the substance.
Bold only what matters
Some departments like key sentences bolded in submissions. That can help, but once half the paper is bold, none of it stands out.
“First”, “Second” and “Third” are also perfectly fine when there is an actual sequence. There is no prize for hiding a list inside decorative prose.
Use simple tenses and active voice where possible
Simple past and present tenses describe almost everything well. Use it together with the active voice to say clearly who should do and own what.
SD(XXX) had said that it had been a case of…
Sentences like this make the reader work out the timeline before understanding the point. Use past tense for what happened and present tense for facts that remain true.
Check out digital.gov for a treatment of the subject, it’s applicable for everything, not just digital stuff.
Keep ROD verbs plain
For records of discussion and minutes, “said”, “replied” and “questioned” cover most situations. “Added” is usually fine.
To be clear, this is a super common thing in minutes I’ve seen, and it’s so common many bosses even vet/edit flowery language.
Truth be told, there is rarely a need to rotate through “highlighted”, “opined”, “queried”, “offered”, “shared” and “noted” just for variety. These words can also change the meaning slightly.
RODs are records, not creative writing.
A record is not a transcript
Capture what mattered to the discussion and decision. Correct factual errors, remove side chatter and leave out material that adds nothing. In practice, stuff that’s wrong get said all the time. You’ll need to decide if you omit it or add an afternote alongside the record to clarify the fact.
Use the ordinary word
Complex words rarely improve a submission. Use ordinary words even in formal papers. They are easier to read and force the writer to be very clear about the meaning to be conveyed.
My language peeves
Some of these are rules. Some are preferences.
- Use “with regard to”, or just “about”. Don’t use “with regards to”.
- Use “reply” instead of “revert” when you mean reply.
- “The committee comprises five members.” It doesn’t “comprise of” five members.
- “Apprise” means inform. “Appraise” means assess. Please don’t appraise Minister when you meant to apprise Minister.
- You leverage something. You don’t “leverage on” it.
- You can tap a person’s skills, but you don’t “tap on their skills”, because that means hitting something gently.
- “I shared X with the meeting” means you told the meeting. “I shared X” means you distributed it.
- I generally treat “input” and “money” as mass nouns in formal writing. Other people differ.
- The “that” in “said that” can often be removed. Some people still prefer to keep it.
- “Please kindly be informed” is too polite. Please stop.
Consistency stuff
Use one term for the same thing throughout the document. The same goes for abbreviations, acronyms, dates and times. Don’t switch between “Singaporean information landscape” and “Singapore information landscape”, or between 1900hrs and 7pm, halfway through.
Be clear when an organisation takes “the” before its name. If an abbreviation needs a possessive, put it after the closing bracket, for example Committee to Strengthen National Service (CSNS)‘s recommendation.
Use full words in formal submissions and emails to bosses. Keep “pls”, “pse”, “tks”, “don’t” and “shouldn’t” for informal messages.
E. Before sending
Read it backwards
For important work, read the document backwards sentence by sentence. It interrupts the flow and makes missing words, repeated phrases and odd sentences easier to see.
When you read normally, your brain is very helpful and fills in what you intended to write.
Try using Pair too. But be warned, Pair is far weaker than the commercial chatbots ChatGPT/Claude/Gemini. It’s weak at longer contexts and it misses much more stuff.
Leave it and come back
Take a short break or return a few hours later where time allows. You will see things that were invisible after staring at the same paper for half a day.
Even ten minutes spent doing something else can help for urgent work.
Check the actual package
Proofreading the source document is only part of the job. Before sending, check:
- Is the correct version attached?
- Are all the annexes there, in the right order?
- Are the addressees and CC list correct?
- Is the classification correct?
- Do the page numbers and cross-references work?
- Are the dates, abbreviations and meeting details consistent?
- Did the PDF conversion break anything?
- Are comments and tracked changes handled properly?
- Does the chart legend say what you think it says?
- Did you open the attachment from the draft email and check that exact file?
I’ve proofread the correct file and attached the wrong one before. The recipient doesn’t care which copy you checked.